feat: lodge server, client and admin
This commit is contained in:
commit
95fc50e2c2
84 files changed
+7625
No files matched your search
@@ -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.
|
||||||
@@ -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 (`<button>`, `<nav>`, etc.) instead of divs with roles
|
||||||
|
|
||||||
|
### Error Handling & Debugging
|
||||||
|
|
||||||
|
- Remove `console.log`, `debugger`, and `alert` statements from production code
|
||||||
|
- Throw `Error` objects with descriptive messages, not strings or other values
|
||||||
|
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
|
||||||
|
- Prefer early returns over nested conditionals for error cases
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
- Keep functions focused and under reasonable cognitive complexity limits
|
||||||
|
- Extract complex conditions into well-named boolean variables
|
||||||
|
- Use early returns to reduce nesting
|
||||||
|
- Prefer simple conditionals over nested ternary operators
|
||||||
|
- Group related code together and separate concerns
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Add `rel="noopener"` when using `target="_blank"` on links
|
||||||
|
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
|
||||||
|
- Don't use `eval()` or assign directly to `document.cookie`
|
||||||
|
- Validate and sanitize user input
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Avoid spread syntax in accumulators within loops
|
||||||
|
- Use top-level regex literals instead of creating them in loops
|
||||||
|
- Prefer specific imports over namespace imports
|
||||||
|
- Avoid barrel files (index files that re-export everything)
|
||||||
|
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
|
||||||
|
|
||||||
|
### Framework-Specific Guidance
|
||||||
|
|
||||||
|
**Next.js:**
|
||||||
|
- Use Next.js `<Image>` component for images
|
||||||
|
- Use `next/head` or App Router metadata API for head elements
|
||||||
|
- Use Server Components for async data fetching instead of async Client Components
|
||||||
|
|
||||||
|
**React 19+:**
|
||||||
|
- Use ref as a prop instead of `React.forwardRef`
|
||||||
|
|
||||||
|
**Solid/Svelte/Vue/Qwik:**
|
||||||
|
- Use `class` and `for` attributes (not `className` or `htmlFor`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Write assertions inside `it()` or `test()` blocks
|
||||||
|
- Avoid done callbacks in async tests - use async/await instead
|
||||||
|
- Don't use `.only` or `.skip` in committed code
|
||||||
|
- Keep test suites reasonably flat - avoid excessive `describe` nesting
|
||||||
|
|
||||||
|
## When Biome Can't Help
|
||||||
|
|
||||||
|
Biome's linter will catch most issues automatically. Focus your attention on:
|
||||||
|
|
||||||
|
1. **Business logic correctness** - Biome can't validate your algorithms
|
||||||
|
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
|
||||||
|
3. **Architecture decisions** - Component structure, data flow, and API design
|
||||||
|
4. **Edge cases** - Handle boundary conditions and error states
|
||||||
|
5. **User experience** - Accessibility, performance, and usability considerations
|
||||||
|
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Most formatting and common issues are automatically fixed by Biome. Run `bun x ultracite fix` before committing to ensure compliance.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "cd frontend && bun run fix --skip=correctness/noUnusedImports",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": "Write|Edit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
_commit: v0.1.0
|
||||||
|
_src_path: https://git.kotikot.com/templates/blank.git
|
||||||
|
layout: part
|
||||||
|
part: backend
|
||||||
|
project_name: lodge
|
||||||
|
project_slug: lodge
|
||||||
|
service: api
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
_commit: v0.1.2
|
||||||
|
_src_path: https://git.kotikot.com/templates/caddy.git
|
||||||
|
project_slug: lodge
|
||||||
|
routes:
|
||||||
|
- api
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
_commit: v0.3.1
|
||||||
|
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
||||||
|
include_impeccable: true
|
||||||
|
install_deps: true
|
||||||
|
layout: part
|
||||||
|
project_name: lodge
|
||||||
|
project_slug: lodge
|
||||||
|
serve: api
|
||||||
|
ui_library: shadcn
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
_commit: v0.4.0
|
||||||
|
_src_path: https://git.kotikot.com/templates/infra.git
|
||||||
|
init_git: true
|
||||||
|
layout: parts
|
||||||
|
parts:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
project_description: sync for files not meant to be in git
|
||||||
|
project_name: lodge
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
COMPOSE_FILE=docker-compose.yml:backend/docker-compose.override.yml:frontend/docker-compose.override.yml
|
||||||
|
COMPOSE_PROFILES=external,services
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
docker-compose.override.yml
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
config.local.json
|
||||||
|
hook.cache.json
|
||||||
|
hook.pending.json
|
||||||
|
review/
|
||||||
|
decision/
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"generatedAt": "2026-09-19T21:29:36Z",
|
||||||
|
"title": "Design System: lodge",
|
||||||
|
"extensions": {
|
||||||
|
"colorMeta": {
|
||||||
|
"survey-green": {
|
||||||
|
"role": "primary",
|
||||||
|
"displayName": "Survey Green",
|
||||||
|
"canonical": "#24694f",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.081 165)",
|
||||||
|
"oklch(27% 0.081 165)",
|
||||||
|
"oklch(38% 0.081 165)",
|
||||||
|
"oklch(50% 0.081 165)",
|
||||||
|
"oklch(61% 0.081 165)",
|
||||||
|
"oklch(73% 0.081 165)",
|
||||||
|
"oklch(84% 0.081 165)",
|
||||||
|
"oklch(95% 0.081 165)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"curve-red": {
|
||||||
|
"role": "secondary",
|
||||||
|
"displayName": "Curve Red",
|
||||||
|
"canonical": "#b3341f",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.167 32)",
|
||||||
|
"oklch(27% 0.167 32)",
|
||||||
|
"oklch(38% 0.167 32)",
|
||||||
|
"oklch(50% 0.167 32)",
|
||||||
|
"oklch(61% 0.167 32)",
|
||||||
|
"oklch(73% 0.167 32)",
|
||||||
|
"oklch(84% 0.167 32)",
|
||||||
|
"oklch(95% 0.167 32)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ochre": {
|
||||||
|
"role": "tertiary",
|
||||||
|
"displayName": "Ochre",
|
||||||
|
"canonical": "#c9a24a",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.115 85)",
|
||||||
|
"oklch(27% 0.115 85)",
|
||||||
|
"oklch(38% 0.115 85)",
|
||||||
|
"oklch(50% 0.115 85)",
|
||||||
|
"oklch(61% 0.115 85)",
|
||||||
|
"oklch(73% 0.115 85)",
|
||||||
|
"oklch(84% 0.115 85)",
|
||||||
|
"oklch(95% 0.115 85)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log-paper": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Log Paper",
|
||||||
|
"canonical": "#eef0e6",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.013 116)",
|
||||||
|
"oklch(27% 0.013 116)",
|
||||||
|
"oklch(38% 0.013 116)",
|
||||||
|
"oklch(50% 0.013 116)",
|
||||||
|
"oklch(61% 0.013 116)",
|
||||||
|
"oklch(73% 0.013 116)",
|
||||||
|
"oklch(84% 0.013 116)",
|
||||||
|
"oklch(95% 0.013 116)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log-paper-2": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Log Paper 2",
|
||||||
|
"canonical": "#e3e7d8",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.020 119)",
|
||||||
|
"oklch(27% 0.020 119)",
|
||||||
|
"oklch(38% 0.020 119)",
|
||||||
|
"oklch(50% 0.020 119)",
|
||||||
|
"oklch(61% 0.020 119)",
|
||||||
|
"oklch(73% 0.020 119)",
|
||||||
|
"oklch(84% 0.020 119)",
|
||||||
|
"oklch(95% 0.020 119)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ink": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Ink",
|
||||||
|
"canonical": "#15211d",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.019 172)",
|
||||||
|
"oklch(27% 0.019 172)",
|
||||||
|
"oklch(38% 0.019 172)",
|
||||||
|
"oklch(50% 0.019 172)",
|
||||||
|
"oklch(61% 0.019 172)",
|
||||||
|
"oklch(73% 0.019 172)",
|
||||||
|
"oklch(84% 0.019 172)",
|
||||||
|
"oklch(95% 0.019 172)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ink-2": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Ink 2",
|
||||||
|
"canonical": "#46544d",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.021 164)",
|
||||||
|
"oklch(27% 0.021 164)",
|
||||||
|
"oklch(38% 0.021 164)",
|
||||||
|
"oklch(50% 0.021 164)",
|
||||||
|
"oklch(61% 0.021 164)",
|
||||||
|
"oklch(73% 0.021 164)",
|
||||||
|
"oklch(84% 0.021 164)",
|
||||||
|
"oklch(95% 0.021 164)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-survey-green": {
|
||||||
|
"role": "primary",
|
||||||
|
"displayName": "Survey Green, night print",
|
||||||
|
"canonical": "#74caa6",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.099 165)",
|
||||||
|
"oklch(27% 0.099 165)",
|
||||||
|
"oklch(38% 0.099 165)",
|
||||||
|
"oklch(50% 0.099 165)",
|
||||||
|
"oklch(61% 0.099 165)",
|
||||||
|
"oklch(73% 0.099 165)",
|
||||||
|
"oklch(84% 0.099 165)",
|
||||||
|
"oklch(95% 0.099 165)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-curve-red": {
|
||||||
|
"role": "secondary",
|
||||||
|
"displayName": "Curve Red, night print",
|
||||||
|
"canonical": "#f08a74",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.129 33)",
|
||||||
|
"oklch(27% 0.129 33)",
|
||||||
|
"oklch(38% 0.129 33)",
|
||||||
|
"oklch(50% 0.129 33)",
|
||||||
|
"oklch(61% 0.129 33)",
|
||||||
|
"oklch(73% 0.129 33)",
|
||||||
|
"oklch(84% 0.129 33)",
|
||||||
|
"oklch(95% 0.129 33)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-ochre": {
|
||||||
|
"role": "tertiary",
|
||||||
|
"displayName": "Ochre, night print",
|
||||||
|
"canonical": "#d9b45e",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.113 86)",
|
||||||
|
"oklch(27% 0.113 86)",
|
||||||
|
"oklch(38% 0.113 86)",
|
||||||
|
"oklch(50% 0.113 86)",
|
||||||
|
"oklch(61% 0.113 86)",
|
||||||
|
"oklch(73% 0.113 86)",
|
||||||
|
"oklch(84% 0.113 86)",
|
||||||
|
"oklch(95% 0.113 86)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-ground": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Night Ground",
|
||||||
|
"canonical": "#162026",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.018 235)",
|
||||||
|
"oklch(27% 0.018 235)",
|
||||||
|
"oklch(38% 0.018 235)",
|
||||||
|
"oklch(50% 0.018 235)",
|
||||||
|
"oklch(61% 0.018 235)",
|
||||||
|
"oklch(73% 0.018 235)",
|
||||||
|
"oklch(84% 0.018 235)",
|
||||||
|
"oklch(95% 0.018 235)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-ground-2": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Night Ground 2",
|
||||||
|
"canonical": "#1d2a31",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.022 232)",
|
||||||
|
"oklch(27% 0.022 232)",
|
||||||
|
"oklch(38% 0.022 232)",
|
||||||
|
"oklch(50% 0.022 232)",
|
||||||
|
"oklch(61% 0.022 232)",
|
||||||
|
"oklch(73% 0.022 232)",
|
||||||
|
"oklch(84% 0.022 232)",
|
||||||
|
"oklch(95% 0.022 232)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-ink": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Night Ink",
|
||||||
|
"canonical": "#e6eadc",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.019 120)",
|
||||||
|
"oklch(27% 0.019 120)",
|
||||||
|
"oklch(38% 0.019 120)",
|
||||||
|
"oklch(50% 0.019 120)",
|
||||||
|
"oklch(61% 0.019 120)",
|
||||||
|
"oklch(73% 0.019 120)",
|
||||||
|
"oklch(84% 0.019 120)",
|
||||||
|
"oklch(95% 0.019 120)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"night-ink-2": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Night Ink 2",
|
||||||
|
"canonical": "#a5b2a8",
|
||||||
|
"tonalRamp": [
|
||||||
|
"oklch(15% 0.020 153)",
|
||||||
|
"oklch(27% 0.020 153)",
|
||||||
|
"oklch(38% 0.020 153)",
|
||||||
|
"oklch(50% 0.020 153)",
|
||||||
|
"oklch(61% 0.020 153)",
|
||||||
|
"oklch(73% 0.020 153)",
|
||||||
|
"oklch(84% 0.020 153)",
|
||||||
|
"oklch(95% 0.020 153)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rule": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Rule (ink at 20%)"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Depth Grid"
|
||||||
|
},
|
||||||
|
"grid-strong": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Depth Grid, 40px line"
|
||||||
|
},
|
||||||
|
"green-wash": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Green Wash"
|
||||||
|
},
|
||||||
|
"red-wash": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Red Wash"
|
||||||
|
},
|
||||||
|
"night-rule": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Rule, night print"
|
||||||
|
},
|
||||||
|
"night-grid": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Depth Grid, night print"
|
||||||
|
},
|
||||||
|
"night-grid-strong": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Depth Grid 40px line, night print"
|
||||||
|
},
|
||||||
|
"night-green-wash": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Green Wash, night print"
|
||||||
|
},
|
||||||
|
"night-red-wash": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Red Wash, night print"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typographyMeta": {
|
||||||
|
"display": {
|
||||||
|
"displayName": "Display",
|
||||||
|
"purpose": "Repository names on the index and the Machines heading; Archivo at 66% width."
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"displayName": "Headline",
|
||||||
|
"purpose": "File name above a note; 1.75rem below 56rem."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"displayName": "Stamp",
|
||||||
|
"purpose": "Condensed tabular stamp for names, depths and counts, 0.8125rem to 1.75rem."
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"displayName": "Body",
|
||||||
|
"purpose": "Interface text; meta lines at 0.875rem in ink-2."
|
||||||
|
},
|
||||||
|
"prose": {
|
||||||
|
"displayName": "Prose",
|
||||||
|
"purpose": "Rendered notes at a 70ch measure."
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"displayName": "Label",
|
||||||
|
"purpose": "Uppercase field and group names; always names the value or list under it."
|
||||||
|
},
|
||||||
|
"mono": {
|
||||||
|
"displayName": "Mono",
|
||||||
|
"purpose": "SHAs, keys, paths, commands, code, source and diffs; ligatures off."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shadows": [],
|
||||||
|
"motion": [
|
||||||
|
{
|
||||||
|
"name": "ease",
|
||||||
|
"value": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
"purpose": "The one easing curve, for every transition and the settle reveal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"value": "150ms",
|
||||||
|
"purpose": "Colour, background, underline and opacity changes on hover and selection."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "settle",
|
||||||
|
"value": "600ms clip-path inset(0 0 100% 0) to inset(0)",
|
||||||
|
"purpose": "A revision arriving over SSE reveals top-down onto the surface. Nothing else animates."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reduced-motion",
|
||||||
|
"value": "1ms",
|
||||||
|
"purpose": "All transition and animation durations under prefers-reduced-motion."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"breakpoints": [
|
||||||
|
{
|
||||||
|
"name": "bar-compact",
|
||||||
|
"value": "40rem"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "machines-stack",
|
||||||
|
"value": "48rem"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rail",
|
||||||
|
"value": "56rem"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sheet-narrow",
|
||||||
|
"value": "72rem"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"themes": {
|
||||||
|
"selector": ".dark",
|
||||||
|
"customProperties": {
|
||||||
|
"light": {
|
||||||
|
"--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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"name": "Text Action",
|
||||||
|
"kind": "button",
|
||||||
|
"refersTo": "action-text",
|
||||||
|
"description": "Default action: green weight-600 text on a 1px rule, wash on hover. No box.",
|
||||||
|
"html": "<button class=\"ds-act\">Restore this version</button>",
|
||||||
|
"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": "<button class=\"ds-act ds-act-danger\">Revoke</button>",
|
||||||
|
"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": "<button class=\"ds-solid\">Sign in</button>",
|
||||||
|
"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": "<button class=\"ds-solid-danger\">Delete with all history</button>",
|
||||||
|
"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": "<label class=\"ds-label\" for=\"ds-token\">Admin token</label><input id=\"ds-token\" class=\"ds-field\" placeholder=\"paste the token\">",
|
||||||
|
"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": "<header class=\"ds-bar\"><a class=\"ds-wordmark\" href=\"#\">lodge</a><nav class=\"ds-nav\"><a href=\"#\" aria-current=\"page\">Repositories</a><a href=\"#\">Machines</a></nav><div class=\"ds-tools\"><button class=\"ds-plain\">Night print</button><button class=\"ds-plain\">Sign out</button></div></header>",
|
||||||
|
"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": "<dl class=\"ds-titleblock\"><div class=\"ds-tb-wide\"><dt>Repository</dt><dd class=\"ds-tb-name\">beavergram</dd></div><div><dt>Depth</dt><dd class=\"ds-tb-stamp\">52 / 64</dd></div><div><dt>Root</dt><dd class=\"ds-tb-mono\">3f3f3f3f3f3f</dd></div></dl>",
|
||||||
|
"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": "<div class=\"ds-column\"><button class=\"ds-bed ds-bed-above\" style=\"height:26px\"><span class=\"ds-depth\">55</span><span class=\"ds-lith ds-lith-0 ds-fill\"></span><span></span><span class=\"ds-formation\"><b>main</b><code>5e7fa16</code></span></button><button class=\"ds-bed\" aria-selected=\"true\" style=\"height:32px\"><span class=\"ds-depth\">52</span><span class=\"ds-lith ds-lith-1 ds-fill\"></span><span class=\"ds-mark\"></span><span class=\"ds-formation\"><b>main</b><code>24b37e7</code></span></button><button class=\"ds-bed\" style=\"height:38px\"><span class=\"ds-depth\"></span><span class=\"ds-lith ds-lith-0 ds-fill\"></span><span></span><span class=\"ds-formation\"></span></button><button class=\"ds-bed\" style=\"height:26px\"><span class=\"ds-depth\">50</span><span class=\"ds-lith ds-lith-web ds-fill\"></span><span></span><span class=\"ds-formation\"></span></button><button class=\"ds-bed\" style=\"height:32px\"><span class=\"ds-depth\"></span><span class=\"ds-lith ds-lith-2 ds-fill\"></span><span class=\"ds-mark\"></span><span class=\"ds-formation\"><b>feature/history</b><code>9fa1c95</code></span></button></div>",
|
||||||
|
"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; topLine truncated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "<div class=\"ds-legend\"><span class=\"ds-swatch ds-lith ds-lith-0\"></span><span class=\"ds-swatch ds-lith ds-lith-1\"></span><span class=\"ds-swatch ds-lith ds-lith-2\"></span><span class=\"ds-swatch ds-lith ds-lith-3\"></span><span class=\"ds-swatch ds-lith ds-lith-4\"></span><span class=\"ds-swatch ds-lith ds-lith-5\"></span><span class=\"ds-swatch ds-lith ds-lith-6\"></span><span class=\"ds-swatch ds-lith ds-lith-web\"></span><span class=\"ds-swatch ds-lith ds-lith-gone\"></span></div>",
|
||||||
|
"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": "<ul class=\"ds-rows\"><li><a href=\"#\"><span class=\"ds-file\"><span class=\"ds-dir\">docs/agents/</span>architecture.md</span><span class=\"ds-track\"><i style=\"left:12%\"></i><i style=\"left:31%\"></i><i style=\"left:48%\"></i><i style=\"left:80%\" class=\"ds-tick-above\"></i></span><span class=\"ds-when\">8 minutes ago</span></a></li><li><a href=\"#\" aria-current=\"true\"><span class=\"ds-file\"><span class=\"ds-dir\">docs/agents/</span>plan.md</span><span class=\"ds-track\"><i style=\"left:5%\"></i><i style=\"left:22%\"></i><i style=\"left:40%\"></i><i style=\"left:66%\"></i></span><span class=\"ds-when\">8 minutes ago</span></a></li></ul>",
|
||||||
|
"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": "<ol class=\"ds-diff\"><li class=\"ds-same\">## Done</li><li class=\"ds-removed\"><del>step 3 pending</del></li><li class=\"ds-added\">step 3 verified on both machines</li><li class=\"ds-gap\">14 unchanged lines</li><li class=\"ds-same\">## Next</li></ol>",
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"svelte": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.svelte.dev/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## 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:<name>`. 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 <url>/i/<code> | 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.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
bin
|
||||||
|
storage
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ADMIN_TOKEN=
|
||||||
|
PUBLIC_URL=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
bin
|
||||||
|
storage
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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 <url> <code> 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 <rev|sha> <path> 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 <url> <code>")
|
||||||
|
}
|
||||||
|
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 <revision|commit-sha> <path>")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
caddy:
|
||||||
|
aliases:
|
||||||
|
- lodge-api
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>%s</string>
|
||||||
|
<key>ProgramArguments</key><array><string>%s</string><string>daemon</string></array>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>ProcessType</key><string>Background</string>
|
||||||
|
<key>StandardErrorPath</key><string>%s</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`, 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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 <path>` or `branch <path>`. 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 <revision|commit-sha> <path>` 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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
LODGE_DOMAIN=lodge.localhost
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
Caddyfile
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{$LODGE_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
reverse_proxy lodge-api:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include:
|
||||||
|
- path: backend/docker-compose.yml
|
||||||
|
project_directory: .
|
||||||
|
- path: frontend/docker-compose.yml
|
||||||
|
project_directory: .
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PUBLIC_API_BASE_URL=/api
|
||||||
|
ALLOWED_HOSTS=
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
## Checking commands
|
||||||
|
After writing code, always run from `frontend/`:
|
||||||
|
```shell
|
||||||
|
bun fix
|
||||||
|
bun check
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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" } } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
frontend-dev:
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
caddy:
|
||||||
|
aliases:
|
||||||
|
- lodge-frontend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
@@ -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:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+13
@@ -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 {};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||||
|
<meta content="scale" name="text-scale">
|
||||||
|
<link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml">
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<string, unknown> | unknown[];
|
||||||
|
|
||||||
|
const send = async (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Json
|
||||||
|
): Promise<Response> => {
|
||||||
|
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 <T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Json
|
||||||
|
): Promise<T> => {
|
||||||
|
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: <T>(path: string) => request<T>("DELETE", path),
|
||||||
|
events: () => new EventSource(`${BASE}/events`, { withCredentials: true }),
|
||||||
|
get: <T>(path: string) => request<T>("GET", path),
|
||||||
|
post: <T>(path: string, body?: Json) => request<T>("POST", path, body),
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { diffLines } from "diff";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
before: string | null;
|
||||||
|
markdown: boolean;
|
||||||
|
now: string;
|
||||||
|
view: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
kind: "added" | "gap" | "removed" | "same";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { before, markdown, now, view }: Props = $props();
|
||||||
|
|
||||||
|
const CONTEXT = 3;
|
||||||
|
const TRAILING_NEWLINE = /\n$/;
|
||||||
|
|
||||||
|
const html = $derived(
|
||||||
|
markdown && view === "read"
|
||||||
|
? DOMPurify.sanitize(marked.parse(now, { async: false }))
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = (value: string) =>
|
||||||
|
value.replace(TRAILING_NEWLINE, "").split("\n");
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
const result: Row[] = [];
|
||||||
|
for (const part of diffLines(before ?? "", now)) {
|
||||||
|
const partLines = lines(part.value);
|
||||||
|
if (part.added || part.removed) {
|
||||||
|
const kind = part.added ? "added" : "removed";
|
||||||
|
result.push(...partLines.map((text): Row => ({ kind, text })));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (partLines.length <= CONTEXT * 2 + 1) {
|
||||||
|
result.push(...partLines.map((text): Row => ({ kind: "same", text })));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hidden = partLines.length - CONTEXT * 2;
|
||||||
|
result.push(
|
||||||
|
...partLines
|
||||||
|
.slice(0, CONTEXT)
|
||||||
|
.map((text): Row => ({ kind: "same", text })),
|
||||||
|
{ kind: "gap", text: `${hidden} unchanged lines` },
|
||||||
|
...partLines
|
||||||
|
.slice(-CONTEXT)
|
||||||
|
.map((text): Row => ({ kind: "same", text }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view === "changes"}
|
||||||
|
<ol class="diff mono">
|
||||||
|
{#each rows as row, index (index)}
|
||||||
|
<li class={row.kind}>
|
||||||
|
{#if row.kind === "removed"}
|
||||||
|
<del>{row.text || " "}</del>
|
||||||
|
{:else if row.kind === "added"}
|
||||||
|
<ins>{row.text || " "}</ins>
|
||||||
|
{:else}
|
||||||
|
{row.text || " "}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{:else if html}
|
||||||
|
<article class="prose">
|
||||||
|
{@html html}
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<pre class="source mono">{now}</pre>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
max-width: 70ch;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(:is(h1, h2, h3, h4)) {
|
||||||
|
margin: 2.25em 0 0.6em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 85%;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h1) {
|
||||||
|
font-size: 1.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h2) {
|
||||||
|
font-size: 1.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h3) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(:is(p, ul, ol, pre, blockquote, table)) {
|
||||||
|
margin: 0 0 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(:is(ul, ol)) {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(ul) {
|
||||||
|
list-style: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(ol) {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(li) {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(li::marker) {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(code) {
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8125em;
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(pre) {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.55;
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(blockquote) {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.25em;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(blockquote::before) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.2em auto 0.2em 0;
|
||||||
|
width: 1px;
|
||||||
|
content: "";
|
||||||
|
background: var(--ochre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(hr) {
|
||||||
|
margin: 2em 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(table) {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(:is(th, td)) {
|
||||||
|
padding: 0.4em 1em 0.4em 0;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source,
|
||||||
|
.diff {
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 2;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff li {
|
||||||
|
position: relative;
|
||||||
|
min-width: max-content;
|
||||||
|
padding: 0 0.75rem 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .same {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .added {
|
||||||
|
background: var(--green-wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .removed {
|
||||||
|
background: var(--red-wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .added::before,
|
||||||
|
.diff .removed::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .added::before {
|
||||||
|
color: var(--green);
|
||||||
|
content: "+";
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .removed::before {
|
||||||
|
color: var(--red);
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff ins {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff del {
|
||||||
|
text-decoration-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff .gap {
|
||||||
|
padding-block: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
border-block: 1px dashed var(--rule);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ago, lithology, type Revision, shortSha } from "$lib/lodge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
at: number;
|
||||||
|
fresh: number;
|
||||||
|
machines: string[];
|
||||||
|
onselect: (seq: number, scrubbing: boolean) => void;
|
||||||
|
revisions: Revision[];
|
||||||
|
touched: Set<number> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { at, fresh, machines, onselect, revisions, touched }: Props = $props();
|
||||||
|
|
||||||
|
const BASE_HEIGHT = 20;
|
||||||
|
const CHANGE_HEIGHT = 6;
|
||||||
|
const MAX_CHANGES = 6;
|
||||||
|
const NUMERAL_EVERY = 5;
|
||||||
|
|
||||||
|
let column = $state<HTMLElement | undefined>();
|
||||||
|
let scrubbing = $state(false);
|
||||||
|
|
||||||
|
const height = (rev: Revision) =>
|
||||||
|
BASE_HEIGHT + Math.min(rev.changes.length, MAX_CHANGES) * CHANGE_HEIGHT;
|
||||||
|
|
||||||
|
const numbered = (rev: Revision, index: number) =>
|
||||||
|
rev.seq === at ||
|
||||||
|
rev.seq % NUMERAL_EVERY === 0 ||
|
||||||
|
index === 0 ||
|
||||||
|
index === revisions.length - 1;
|
||||||
|
|
||||||
|
const formation = (rev: Revision, index: number) =>
|
||||||
|
index === 0 || revisions[index - 1].main_sha !== rev.main_sha;
|
||||||
|
|
||||||
|
const describeBed = (rev: Revision) =>
|
||||||
|
`Revision ${rev.seq}, ${rev.machine}, ${rev.changes.length} changed, ${rev.main_branch || "detached"} ${shortSha(rev.main_sha)}, ${ago(rev.time)}`;
|
||||||
|
|
||||||
|
const seqAt = (x: number, y: number): number | null => {
|
||||||
|
const bed = document.elementFromPoint(x, y)?.closest("[data-seq]");
|
||||||
|
return bed instanceof HTMLElement ? Number(bed.dataset.seq) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const press = (event: PointerEvent) => {
|
||||||
|
const seq = seqAt(event.clientX, event.clientY);
|
||||||
|
if (seq === null || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.pointerType === "mouse" || seq === at) {
|
||||||
|
scrubbing = true;
|
||||||
|
column?.setPointerCapture(event.pointerId);
|
||||||
|
onselect(seq, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drag = (event: PointerEvent) => {
|
||||||
|
if (!(scrubbing && column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = column.getBoundingClientRect();
|
||||||
|
const seq = seqAt(bounds.left + bounds.width / 2, event.clientY);
|
||||||
|
if (seq !== null && seq !== at) {
|
||||||
|
onselect(seq, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
if (scrubbing) {
|
||||||
|
scrubbing = false;
|
||||||
|
onselect(at, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const step = (event: KeyboardEvent) => {
|
||||||
|
const move = { ArrowDown: 1, ArrowUp: -1, j: 1, k: -1 }[event.key];
|
||||||
|
if (!move) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const index = revisions.findIndex((rev) => rev.seq === at) + move;
|
||||||
|
const next = revisions[Math.max(0, Math.min(revisions.length - 1, index))];
|
||||||
|
onselect(next.seq, false);
|
||||||
|
column?.querySelector<HTMLElement>(`[data-seq="${next.seq}"]`)?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
column
|
||||||
|
?.querySelector(`[data-seq="${at}"]`)
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Revision history, newest at the top"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
class="column"
|
||||||
|
onkeydown={step}
|
||||||
|
onpointercancel={release}
|
||||||
|
onpointerdown={press}
|
||||||
|
onpointermove={drag}
|
||||||
|
onpointerup={release}
|
||||||
|
role="listbox"
|
||||||
|
tabindex="-1"
|
||||||
|
bind:this={column}
|
||||||
|
class:scrubbing
|
||||||
|
>
|
||||||
|
{#each revisions as rev, index (rev.seq)}
|
||||||
|
<button
|
||||||
|
aria-label={describeBed(rev)}
|
||||||
|
aria-selected={rev.seq === at}
|
||||||
|
class="bed"
|
||||||
|
data-seq={rev.seq}
|
||||||
|
onclick={() => onselect(rev.seq, false)}
|
||||||
|
role="option"
|
||||||
|
title="{rev.machine} · {ago(rev.time)}"
|
||||||
|
type="button"
|
||||||
|
style:height="{height(rev)}px"
|
||||||
|
class:above={rev.seq > at}
|
||||||
|
class:fresh={rev.seq === fresh}
|
||||||
|
class:quiet={touched !== null && !touched.has(rev.seq)}
|
||||||
|
>
|
||||||
|
<span class="depth stamp">{numbered(rev, index) ? rev.seq : ""}</span>
|
||||||
|
<span class="fill {lithology(rev.machine, machines)}"></span>
|
||||||
|
<span class="mark"></span>
|
||||||
|
<span class="formation">
|
||||||
|
{#if formation(rev, index)}
|
||||||
|
<span class="branch">{rev.main_branch || "detached"}</span>
|
||||||
|
<span class="mono">{shortSha(rev.main_sha)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
user-select: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.scrubbing {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
grid-template-columns: 2.75rem 4.5rem 0.5rem 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
touch-action: pan-y;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed.fresh {
|
||||||
|
animation: settle 600ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth {
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
border-inline: 1px solid var(--ink);
|
||||||
|
transition: opacity 150ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed.quiet .fill {
|
||||||
|
--l: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed.quiet .fill::after {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed:not(.quiet) .mark {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column:not(:has(.quiet)) .mark {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed.above .fill::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 600;
|
||||||
|
font-stretch: 80%;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed:hover .fill {
|
||||||
|
--l: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed[aria-selected="true"] {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed[aria-selected="true"]::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px 0 auto;
|
||||||
|
z-index: 1;
|
||||||
|
height: 3px;
|
||||||
|
content: "";
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed:first-child[aria-selected="true"]::after {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed:first-child[aria-selected="true"]::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed[aria-selected="true"] .depth {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bed:focus-visible {
|
||||||
|
z-index: 2;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 56rem) {
|
||||||
|
.bed {
|
||||||
|
grid-template-columns: 1.75rem 1fr 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth {
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
// biome-ignore-all lint/suspicious/noEmptySource: temporary
|
||||||
@@ -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<string, Record<string, FileEntry>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Promise<Uint8Array>>();
|
||||||
|
|
||||||
|
export const blob = (hash: string): Promise<Uint8Array> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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<void> => {
|
||||||
|
try {
|
||||||
|
await api.get("/session");
|
||||||
|
session.signedIn = true;
|
||||||
|
} catch {
|
||||||
|
session.signedIn = false;
|
||||||
|
await goto("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signOut = async (): Promise<void> => {
|
||||||
|
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.";
|
||||||
|
};
|
||||||
@@ -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> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
|
export type WithoutChildren<T> = T extends { children?: any }
|
||||||
|
? Omit<T, "children">
|
||||||
|
: T;
|
||||||
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||||
|
ref?: U | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import "./layout.css";
|
||||||
|
import { ModeWatcher, mode, toggleMode } from "mode-watcher";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { requireSession, session, signOut } from "$lib/session.svelte";
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const onLogin = $derived(page.url.pathname === "/login");
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!onLogin) {
|
||||||
|
requireSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModeWatcher />
|
||||||
|
|
||||||
|
{#if onLogin}
|
||||||
|
{@render children()}
|
||||||
|
{:else if session.signedIn}
|
||||||
|
<header class="bar">
|
||||||
|
<a class="wordmark" href="/">lodge</a>
|
||||||
|
<nav aria-label="Sections">
|
||||||
|
<a aria-current={page.url.pathname === "/" ? "page" : undefined} href="/"
|
||||||
|
>Repositories</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-current={page.url.pathname === "/machines" ? "page" : undefined}
|
||||||
|
href="/machines"
|
||||||
|
>Machines</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
<div class="tools">
|
||||||
|
<button class="plain" onclick={toggleMode} type="button">
|
||||||
|
{mode.current === "dark" ? "Day print" : "Night print"}
|
||||||
|
</button>
|
||||||
|
<button class="plain" onclick={signOut} type="button">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-stretch: 68%;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a[aria-current="page"] {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-decoration-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plain {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
transition: color 150ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plain:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.bar {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
gap: 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import {
|
||||||
|
ago,
|
||||||
|
exact,
|
||||||
|
lithology,
|
||||||
|
type Machine,
|
||||||
|
type Repo,
|
||||||
|
type Revision,
|
||||||
|
} from "$lib/lodge";
|
||||||
|
import { describe } from "$lib/session.svelte";
|
||||||
|
|
||||||
|
const CORE_BEDS = 60;
|
||||||
|
const KEY_LENGTH = 7;
|
||||||
|
|
||||||
|
let repos = $state<Repo[] | null>(null);
|
||||||
|
let machines: string[] = $state([]);
|
||||||
|
let cores: Record<string, Revision[]> = $state({});
|
||||||
|
let problem = $state("");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [list, known] = await Promise.all([
|
||||||
|
api.get<Repo[]>("/repos"),
|
||||||
|
api.get<Machine[]>("/machines"),
|
||||||
|
]);
|
||||||
|
repos = list;
|
||||||
|
machines = known.map((machine) => machine.name);
|
||||||
|
await Promise.all(
|
||||||
|
list.map(async (repo) => {
|
||||||
|
const revisions = await api.get<Revision[]>(
|
||||||
|
`/repos/${repo.key}/revisions`
|
||||||
|
);
|
||||||
|
cores[repo.key] = revisions.slice(0, CORE_BEDS);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
const events = api.events();
|
||||||
|
events.onmessage = load;
|
||||||
|
return () => events.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Repositories · lodge</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Repositories</h1>
|
||||||
|
|
||||||
|
{#if problem}
|
||||||
|
<p class="problem" role="alert">{problem}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if repos === null && !problem}
|
||||||
|
<div class="skeleton row-skeleton"></div>
|
||||||
|
<div class="skeleton row-skeleton"></div>
|
||||||
|
{:else if repos?.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<p>
|
||||||
|
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 <span class="mono">lodge add</span>.
|
||||||
|
</p>
|
||||||
|
<a class="act" href="/machines">Connect a machine</a>
|
||||||
|
</div>
|
||||||
|
{:else if repos}
|
||||||
|
<ul>
|
||||||
|
{#each repos as repo (repo.key)}
|
||||||
|
<li>
|
||||||
|
<a href="/r/{repo.key}">
|
||||||
|
<span class="name">{repo.name}</span>
|
||||||
|
<span class="facts">
|
||||||
|
<span class="mono" title={repo.key}
|
||||||
|
>{repo.key.slice(0, KEY_LENGTH)}</span
|
||||||
|
>
|
||||||
|
<span class="stamp">{repo.seq}</span>
|
||||||
|
{repo.seq === 1 ? "revision" : "revisions"}, last
|
||||||
|
<time
|
||||||
|
datetime={new Date(repo.time).toISOString()}
|
||||||
|
title={exact(repo.time)}
|
||||||
|
>{ago(repo.time)}</time
|
||||||
|
>
|
||||||
|
{#if cores[repo.key]?.[0]}
|
||||||
|
by {cores[repo.key][0].machine}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" class="core">
|
||||||
|
{#each cores[repo.key] ?? [] as rev (rev.seq)}
|
||||||
|
<i
|
||||||
|
class={lithology(rev.machine, machines)}
|
||||||
|
style:flex-grow={Math.min(rev.changes.length, 6)}
|
||||||
|
></i>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
max-width: 72rem;
|
||||||
|
padding: 3rem 1.5rem 6rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.25rem 2rem;
|
||||||
|
padding: 1.25rem 0 2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: clamp(2.25rem, 6vw, 4rem);
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 66%;
|
||||||
|
line-height: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
transition: color 150ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a:hover .name {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
align-self: end;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts .stamp {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 1.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core i {
|
||||||
|
flex: 1 0 0.5rem;
|
||||||
|
max-width: 6rem;
|
||||||
|
border-right: 1px solid var(--ink);
|
||||||
|
transition: background-color 150ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a:hover .core i {
|
||||||
|
--l: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
justify-items: start;
|
||||||
|
max-width: 38rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-skeleton {
|
||||||
|
height: 7rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
main {
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li a {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { ApiError, api } from "$lib/api/client";
|
||||||
|
import { describe, session } from "$lib/session.svelte";
|
||||||
|
|
||||||
|
let password = $state("");
|
||||||
|
let busy = $state(false);
|
||||||
|
let problem = $state("");
|
||||||
|
|
||||||
|
const submit = async (event: SubmitEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
busy = true;
|
||||||
|
problem = "";
|
||||||
|
try {
|
||||||
|
await api.post("/session", { password });
|
||||||
|
session.signedIn = true;
|
||||||
|
await goto("/");
|
||||||
|
} catch (error) {
|
||||||
|
problem =
|
||||||
|
error instanceof ApiError && error.status === 401
|
||||||
|
? "That is not the admin token. Use the ADMIN_TOKEN value from the server's .env file."
|
||||||
|
: describe(error);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sign in · lodge</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div aria-hidden="true" class="core lith lith-1"></div>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<h1>lodge</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Agent notes for every repository, at every depth of their history.
|
||||||
|
</p>
|
||||||
|
<label class="label" for="password">Admin token</label>
|
||||||
|
<input
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="field mono"
|
||||||
|
id="password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
>
|
||||||
|
{#if problem}
|
||||||
|
<p class="problem" role="alert">{problem}</p>
|
||||||
|
{/if}
|
||||||
|
<button class="act solid" disabled={busy} type="submit">
|
||||||
|
{busy ? "Opening…" : "Open the log"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: clamp(3rem, 12vw, 9rem) minmax(0, 26rem);
|
||||||
|
gap: clamp(1.5rem, 5vw, 4rem);
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core {
|
||||||
|
--l: var(--green);
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-self: center;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(3.5rem, 10vw, 6rem);
|
||||||
|
font-weight: 800;
|
||||||
|
font-stretch: 62%;
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
max-width: 22rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import { ago, type Code, exact, lithology, type Machine } from "$lib/lodge";
|
||||||
|
import { describe } from "$lib/session.svelte";
|
||||||
|
|
||||||
|
const SECOND = 1000;
|
||||||
|
const MINUTE = 60;
|
||||||
|
|
||||||
|
let machines = $state<Machine[] | null>(null);
|
||||||
|
let problem = $state("");
|
||||||
|
let code = $state<Code | null>(null);
|
||||||
|
let now = $state(Date.now());
|
||||||
|
let copied = $state(false);
|
||||||
|
let revoking = $state(0);
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const names = $derived(machines?.map((machine) => machine.name) ?? []);
|
||||||
|
const remaining = $derived(
|
||||||
|
code ? Math.max(0, Math.round((code.expires_at - now) / SECOND)) : 0
|
||||||
|
);
|
||||||
|
const countdown = $derived(
|
||||||
|
`${Math.floor(remaining / MINUTE)}:${String(remaining % MINUTE).padStart(2, "0")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
machines = await api.get<Machine[]>("/machines");
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invite = async () => {
|
||||||
|
busy = true;
|
||||||
|
copied = false;
|
||||||
|
try {
|
||||||
|
code = await api.post<Code>("/codes");
|
||||||
|
problem = "";
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
if (code) {
|
||||||
|
await navigator.clipboard.writeText(code.command);
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (id: number) => {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.del(`/machines/${id}`);
|
||||||
|
revoking = 0;
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
if (code && remaining === 0) {
|
||||||
|
code = null;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}, SECOND);
|
||||||
|
const events = api.events();
|
||||||
|
events.onmessage = load;
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
events.close();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Machines · lodge</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Machines</h1>
|
||||||
|
<p class="lede">
|
||||||
|
The legend of the log. Each machine owns a hatch, and every bed it writes is
|
||||||
|
drawn with it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if problem}
|
||||||
|
<p class="problem" role="alert">{problem}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="invite">
|
||||||
|
{#if code && remaining > 0}
|
||||||
|
<p>
|
||||||
|
Run this on the new machine. It installs the client, signs in and starts
|
||||||
|
the daemon. The link works once and expires in
|
||||||
|
<span class="stamp">{countdown}</span>.
|
||||||
|
</p>
|
||||||
|
<pre class="mono command">{code.command}</pre>
|
||||||
|
<div class="row">
|
||||||
|
<button class="act solid" onclick={copy} type="button">
|
||||||
|
{copied ? "Copied" : "Copy command"}
|
||||||
|
</button>
|
||||||
|
<button class="act" disabled={busy} onclick={invite} type="button">
|
||||||
|
New link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="act solid" disabled={busy} onclick={invite} type="button">
|
||||||
|
Connect a machine
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if machines === null && !problem}
|
||||||
|
<div class="skeleton row-skeleton"></div>
|
||||||
|
{:else if machines?.length === 0}
|
||||||
|
<p class="empty">
|
||||||
|
No machine is connected yet. The command above works on macOS and Linux,
|
||||||
|
on Intel and ARM.
|
||||||
|
</p>
|
||||||
|
{:else if machines}
|
||||||
|
<ul>
|
||||||
|
{#each machines as machine (machine.id)}
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="swatch {lithology(machine.name, names)}"
|
||||||
|
></span>
|
||||||
|
<span class="name stamp">{machine.name}</span>
|
||||||
|
<span class="facts">
|
||||||
|
<span class="stamp">{machine.revisions}</span>
|
||||||
|
{machine.revisions === 1 ? "revision" : "revisions"}, last push
|
||||||
|
{ago(machine.last_push)}. Connected
|
||||||
|
<time
|
||||||
|
datetime={new Date(machine.created_at).toISOString()}
|
||||||
|
title={exact(machine.created_at)}
|
||||||
|
>{ago(machine.created_at)}</time
|
||||||
|
>.
|
||||||
|
</span>
|
||||||
|
<span class="action">
|
||||||
|
{#if revoking === machine.id}
|
||||||
|
<span class="confirm">
|
||||||
|
Its token stops working at once; files on it stay.
|
||||||
|
<button
|
||||||
|
class="act danger"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => revoke(machine.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Revoke {machine.name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="act"
|
||||||
|
onclick={() => {
|
||||||
|
revoking = 0;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Keep
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="act danger"
|
||||||
|
onclick={() => {
|
||||||
|
revoking = machine.id;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
<li>
|
||||||
|
<span aria-hidden="true" class="swatch lith lith-web"></span>
|
||||||
|
<span class="name stamp">web</span>
|
||||||
|
<span class="facts">Versions restored from this page.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
max-width: 72rem;
|
||||||
|
padding: 3rem 1.5rem 6rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.25rem, 6vw, 4rem);
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 66%;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-items: start;
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 2.5rem 0 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
user-select: all;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border-left: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 6rem minmax(8rem, 14rem) minmax(0, 1fr) auto;
|
||||||
|
gap: 0.25rem 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
height: 3rem;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts .stamp {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
max-width: 22rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
max-width: 34rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-skeleton {
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
main {
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
grid-template-columns: 4rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
grid-row: span 3;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts,
|
||||||
|
.action {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import Note from "$lib/components/Note.svelte";
|
||||||
|
import StripLog from "$lib/components/StripLog.svelte";
|
||||||
|
import {
|
||||||
|
ago,
|
||||||
|
blob,
|
||||||
|
changeKind,
|
||||||
|
exact,
|
||||||
|
fileId,
|
||||||
|
type Machine,
|
||||||
|
type Repo,
|
||||||
|
type RepoState,
|
||||||
|
type Revision,
|
||||||
|
SHARED,
|
||||||
|
shortSha,
|
||||||
|
spaceName,
|
||||||
|
text,
|
||||||
|
} from "$lib/lodge";
|
||||||
|
import { describe } from "$lib/session.svelte";
|
||||||
|
|
||||||
|
interface Opened {
|
||||||
|
before: string | null;
|
||||||
|
now: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_LENGTH = 12;
|
||||||
|
const TRACK_TICKS = 100;
|
||||||
|
|
||||||
|
const key = $derived(page.params.key ?? "");
|
||||||
|
const query = $derived(page.url.searchParams);
|
||||||
|
const space = $derived(query.get("space") ?? "");
|
||||||
|
const path = $derived(query.get("path") ?? "");
|
||||||
|
const requestedView = $derived(query.get("view") ?? "read");
|
||||||
|
|
||||||
|
let revisions: Revision[] = $state([]);
|
||||||
|
let machines: string[] = $state([]);
|
||||||
|
let name = $state("");
|
||||||
|
let loaded = $state(false);
|
||||||
|
let problem = $state("");
|
||||||
|
let fresh = $state(0);
|
||||||
|
let repoState = $state<RepoState | null>(null);
|
||||||
|
let opened = $state<Opened | null>(null);
|
||||||
|
let confirming = $state("");
|
||||||
|
let typedName = $state("");
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const states = new Map<number, Promise<RepoState>>();
|
||||||
|
|
||||||
|
const latest = $derived(revisions[0]?.seq ?? 0);
|
||||||
|
const at = $derived(Number(query.get("at")) || latest);
|
||||||
|
const bed = $derived(revisions.find((rev) => rev.seq === at));
|
||||||
|
const markdown = $derived(path.toLowerCase().endsWith(".md"));
|
||||||
|
const view = $derived(
|
||||||
|
!markdown && requestedView === "read" ? "source" : requestedView
|
||||||
|
);
|
||||||
|
|
||||||
|
const history = $derived.by(() => {
|
||||||
|
const byFile = new Map<string, Revision[]>();
|
||||||
|
for (const rev of revisions) {
|
||||||
|
for (const change of rev.changes) {
|
||||||
|
const id = fileId(change.space, change.path);
|
||||||
|
byFile.set(id, [...(byFile.get(id) ?? []), rev]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byFile;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileHistory = $derived(history.get(fileId(space, path)) ?? []);
|
||||||
|
const touched = $derived(
|
||||||
|
path ? new Set(fileHistory.map((rev) => rev.seq)) : null
|
||||||
|
);
|
||||||
|
const entry = $derived(repoState?.spaces[space]?.[path]);
|
||||||
|
const written = $derived(fileHistory.find((rev) => rev.seq <= at));
|
||||||
|
const writtenChange = $derived(
|
||||||
|
written?.changes.find(
|
||||||
|
(change) => change.space === space && change.path === path
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const surfaceHash = $derived(
|
||||||
|
fileHistory[0]?.changes.find(
|
||||||
|
(change) => change.space === space && change.path === path
|
||||||
|
)?.hash ?? ""
|
||||||
|
);
|
||||||
|
const restorable = $derived(Boolean(entry) && entry?.hash !== surfaceHash);
|
||||||
|
|
||||||
|
const spaces = $derived(
|
||||||
|
Object.keys(repoState?.spaces ?? {}).sort((a, b) => {
|
||||||
|
if (a === SHARED || b === SHARED) {
|
||||||
|
return a === SHARED ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const href = (patch: Record<string, string | number>) => {
|
||||||
|
const next = new URLSearchParams(query);
|
||||||
|
for (const [param, value] of Object.entries(patch)) {
|
||||||
|
if (value === "" || (param === "at" && value === latest)) {
|
||||||
|
next.delete(param);
|
||||||
|
} else {
|
||||||
|
next.set(param, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const search = next.toString();
|
||||||
|
return search ? `?${search}` : page.url.pathname;
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (seq: number, scrubbing: boolean) => {
|
||||||
|
confirming = "";
|
||||||
|
goto(href({ at: seq }), {
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
replaceState: scrubbing,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticks = (space_: string, path_: string) => {
|
||||||
|
const revs = history.get(fileId(space_, path_)) ?? [];
|
||||||
|
const oldest = revisions.at(-1)?.seq ?? 0;
|
||||||
|
const span = Math.max(1, latest - oldest);
|
||||||
|
return revs.map((rev) => ({
|
||||||
|
above: rev.seq > at,
|
||||||
|
left: ((rev.seq - oldest) / span) * TRACK_TICKS,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [revs, repos, known] = await Promise.all([
|
||||||
|
api.get<Revision[]>(`/repos/${key}/revisions`),
|
||||||
|
api.get<Repo[]>("/repos"),
|
||||||
|
api.get<Machine[]>("/machines"),
|
||||||
|
]);
|
||||||
|
revisions = revs;
|
||||||
|
name = repos.find((repo) => repo.key === key)?.name ?? "";
|
||||||
|
machines = known.map((machine) => machine.name);
|
||||||
|
problem = "";
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateAt = (seq: number) => {
|
||||||
|
let pending = states.get(seq);
|
||||||
|
if (!pending) {
|
||||||
|
pending = api.get<RepoState>(`/repos/${key}/state?at=${seq}`);
|
||||||
|
states.set(seq, pending);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readText = async (hash: string | undefined) =>
|
||||||
|
hash ? text(await blob(hash)) : "";
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!at) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stale = false;
|
||||||
|
stateAt(at)
|
||||||
|
.then((value) => {
|
||||||
|
if (!stale) {
|
||||||
|
repoState = value;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
states.delete(at);
|
||||||
|
problem = describe(error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const hash = entry?.hash;
|
||||||
|
const base = writtenChange?.base;
|
||||||
|
if (!hash) {
|
||||||
|
opened = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stale = false;
|
||||||
|
Promise.all([readText(hash), readText(base)])
|
||||||
|
.then(([now, before]) => {
|
||||||
|
if (!stale) {
|
||||||
|
opened = { before, now };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
problem = describe(error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const restore = async () => {
|
||||||
|
if (!(entry && revisions[0])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/repos/${key}/revisions`, {
|
||||||
|
changes: [{ base: surfaceHash, hash: entry.hash, path, space }],
|
||||||
|
main_branch: revisions[0].main_branch,
|
||||||
|
main_sha: revisions[0].main_sha,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
confirming = "";
|
||||||
|
await load();
|
||||||
|
await goto(href({ at: "" }), { noScroll: true });
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.del(`/repos/${key}`);
|
||||||
|
await goto("/");
|
||||||
|
} catch (error) {
|
||||||
|
problem = describe(error);
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
const events = api.events();
|
||||||
|
events.onmessage = async (message) => {
|
||||||
|
const event = JSON.parse(message.data) as { repo: string; seq: number };
|
||||||
|
if (event.repo === key && event.seq) {
|
||||||
|
await load();
|
||||||
|
fresh = event.seq;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
{path ? `${path.split("/").at(-1)} · ` : ""}{name || "repository"}
|
||||||
|
· lodge
|
||||||
|
</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#snippet slashed(value: string)}
|
||||||
|
{#each value.split("/") as part, index (index)}
|
||||||
|
{index ? "/" : ""}<wbr>{part}
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="sheet" class:reading={Boolean(path)}>
|
||||||
|
<aside class="log">
|
||||||
|
<dl class="titleblock">
|
||||||
|
<div class="wide">
|
||||||
|
<dt class="label">Repository</dt>
|
||||||
|
<dd class="name stamp">{name || "…"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="label">Depth</dt>
|
||||||
|
<dd class="stamp">{at} / {latest}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="label">Root</dt>
|
||||||
|
<dd class="mono" title={key}>{key.slice(0, KEY_LENGTH)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if loaded}
|
||||||
|
<StripLog
|
||||||
|
{at}
|
||||||
|
{fresh}
|
||||||
|
{machines}
|
||||||
|
onselect={select}
|
||||||
|
{revisions}
|
||||||
|
{touched}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="skeleton column-skeleton"></div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section aria-label="Files at this depth" class="files">
|
||||||
|
{#if problem}
|
||||||
|
<p class="problem" role="alert">{problem}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if bed}
|
||||||
|
<div class="bedinfo">
|
||||||
|
<h2 class="stamp">
|
||||||
|
Depth {bed.seq}{bed.seq === latest ? ", the surface" : ""}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
<strong>{bed.machine}</strong>
|
||||||
|
<time
|
||||||
|
datetime={new Date(bed.time).toISOString()}
|
||||||
|
title={exact(bed.time)}
|
||||||
|
>{ago(bed.time)}</time
|
||||||
|
>
|
||||||
|
on {bed.main_branch || "detached"}
|
||||||
|
<span class="mono">{shortSha(bed.main_sha)}</span>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{#each bed.changes as change (fileId(change.space, change.path))}
|
||||||
|
<li class={changeKind(change)}>
|
||||||
|
<span class="kind">{changeKind(change)}</span>
|
||||||
|
{#if change.hash}
|
||||||
|
<a
|
||||||
|
href={href({ path: change.path, space: change.space, view: "changes" })}
|
||||||
|
>{@render slashed(change.path)}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span>{@render slashed(change.path)}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if bed.seq !== latest}
|
||||||
|
<a class="surface" href={href({ at: "" })}>Back to the surface</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each spaces as group (group)}
|
||||||
|
<h3 class="label space">
|
||||||
|
{group === SHARED ? "Shared notes" : `Branch ${spaceName(group)}`}
|
||||||
|
</h3>
|
||||||
|
<ul class="rows">
|
||||||
|
{#each Object.entries(repoState?.spaces[group] ?? {}).sort( ([a], [b]) => a.localeCompare(b) ) as [ file, info ] (file)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
aria-current={group === space && file === path ? "true" : undefined}
|
||||||
|
href={href({ path: file, space: group, view: "" })}
|
||||||
|
>
|
||||||
|
<span class="file">
|
||||||
|
<span class="dir"
|
||||||
|
>{@render slashed(file.slice(0, file.lastIndexOf("/") + 1))}</span
|
||||||
|
>{file.slice(file.lastIndexOf("/") + 1)}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" class="track">
|
||||||
|
{#each ticks(group, file) as tick (tick.left)}
|
||||||
|
<i style:left="{tick.left}%" class:above={tick.above}></i>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="when">{ago(info.time)}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
{#if loaded && !problem}
|
||||||
|
<p class="empty">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<details class="removal">
|
||||||
|
<summary>Delete this repository</summary>
|
||||||
|
<p>
|
||||||
|
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 <strong>{name}</strong> to confirm.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
aria-label="Repository name"
|
||||||
|
class="field"
|
||||||
|
placeholder={name}
|
||||||
|
bind:value={typedName}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="act solid danger"
|
||||||
|
disabled={busy || typedName !== name || !name}
|
||||||
|
onclick={remove}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete with all history
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="reader">
|
||||||
|
{#if path}
|
||||||
|
<a class="back" href={href({ path: "", space: "", view: "" })}>Files</a>
|
||||||
|
<h1>{path.split("/").at(-1)}</h1>
|
||||||
|
<p class="meta">
|
||||||
|
{space === SHARED ? "shared" : `branch ${spaceName(space)}`}
|
||||||
|
<span class="mono">{path}</span>
|
||||||
|
{#if written}
|
||||||
|
written at depth {written.seq} by {written.machine},
|
||||||
|
<time
|
||||||
|
datetime={new Date(written.time).toISOString()}
|
||||||
|
title={exact(written.time)}
|
||||||
|
>{ago(written.time)}</time
|
||||||
|
>, on
|
||||||
|
<span class="together"
|
||||||
|
>{written.main_branch || "detached"}
|
||||||
|
<span class="mono">{shortSha(written.main_sha)}</span></span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if entry}
|
||||||
|
<div class="controls">
|
||||||
|
{#each [["read", "Read"], ["changes", "Changes"], ["source", "Source"]] as [ id, title ] (id)}
|
||||||
|
{#if markdown || id !== "read"}
|
||||||
|
<a
|
||||||
|
aria-current={view === id ? "true" : undefined}
|
||||||
|
href={href({ view: id })}
|
||||||
|
>{title}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if restorable}
|
||||||
|
{#if confirming === "restore"}
|
||||||
|
<span class="confirm">
|
||||||
|
Put depth {written?.seq} back on the surface?
|
||||||
|
<button
|
||||||
|
class="act"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={restore}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="act danger"
|
||||||
|
onclick={() => {
|
||||||
|
confirming = "";
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Keep current
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="act"
|
||||||
|
onclick={() => {
|
||||||
|
confirming = "restore";
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Restore this version
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if opened === null}
|
||||||
|
<div class="skeleton text-skeleton"></div>
|
||||||
|
{:else if opened.now === null}
|
||||||
|
<p class="empty">
|
||||||
|
This file is binary, so there is nothing to read here. Fetch it with
|
||||||
|
<span class="mono">lodge show {at} {path}</span>.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<Note before={opened.before} {markdown} now={opened.now} {view} />
|
||||||
|
{/if}
|
||||||
|
{:else if repoState}
|
||||||
|
<p class="empty">
|
||||||
|
{path.split("/").at(-1)}
|
||||||
|
does not exist at depth {at}.
|
||||||
|
{#if fileHistory.length}
|
||||||
|
It was last written at depth
|
||||||
|
<a href={href({ at: fileHistory[0].seq })}>{fileHistory[0].seq}</a>.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="empty hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sheet {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 17.5rem 20rem minmax(0, 1fr);
|
||||||
|
height: calc(100dvh - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
border-right: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-bottom: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock div {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock div + div + div {
|
||||||
|
border-left: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-skeleton {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files,
|
||||||
|
.reader {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files {
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bedinfo {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bedinfo h2 {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bedinfo p,
|
||||||
|
.meta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bedinfo strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bedinfo ul {
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bedinfo li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4.25rem minmax(0, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-stretch: 80%;
|
||||||
|
line-height: 1.3125rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.added .kind {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted .kind,
|
||||||
|
.deleted span + span {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted span + span {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space {
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
border-bottom: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows a {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.125rem 0.75rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.5rem 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
transition: background-color 150ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows a:hover {
|
||||||
|
color: inherit;
|
||||||
|
background: var(--green-wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows a[aria-current="true"] {
|
||||||
|
color: var(--paper);
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir {
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.when {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
grid-row: 2;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
align-self: center;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-bottom: 1px solid currentColor;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track i {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track i.above {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
max-width: 34rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 20vh;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removal {
|
||||||
|
margin-top: 4rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removal summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removal[open] {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removal p {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removal .act {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader {
|
||||||
|
padding: 2rem clamp(1.5rem, 4vw, 4rem) 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 75%;
|
||||||
|
line-height: 1.05;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.together {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin: 1.5rem 0 2rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls a {
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls a[aria-current="true"] {
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-decoration-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls > :is(button, .confirm) {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-skeleton {
|
||||||
|
max-width: 70ch;
|
||||||
|
height: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 72rem) {
|
||||||
|
.sheet {
|
||||||
|
grid-template-columns: 15rem 17rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 56rem) {
|
||||||
|
.sheet {
|
||||||
|
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock div:not(.wide) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock .wide {
|
||||||
|
padding: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleblock .label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files,
|
||||||
|
.reader {
|
||||||
|
padding: 1.25rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet.reading .files,
|
||||||
|
.sheet:not(.reading) .reader {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>lodge</title>
|
||||||
|
<rect fill="#eef0e6" height="32" width="32" />
|
||||||
|
<rect fill="#24694f" height="7" width="14" x="9" y="3" />
|
||||||
|
<rect fill="#c9a24a" height="4" width="14" x="9" y="11" />
|
||||||
|
<rect fill="#33444c" height="9" width="14" x="9" y="16" />
|
||||||
|
<rect fill="#24694f" height="3" width="14" x="9" y="26" />
|
||||||
|
<rect fill="#b3341f" height="2" width="24" x="4" y="15" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["local>templates/renovate"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user