corpus

Log | Files | Refs | README | LICENSE

commit 46342937d0b13c07b83b52da390a90121ea8ccff
parent 624f573fb457573984b394644455ca92eeb11b1b
Author: mtmn <miro@haravara.org>
Date:   Fri, 24 Apr 2026 20:17:02 +0200

chore: install purescript-language-server

Diffstat:
Mflake.nix | 2+-
Mpackage-lock.json | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 1+
Asrc/Command.js | 1+
Asrc/Command.purs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Main.purs | 42++++++++++++++++++++++++------------------
6 files changed, 282 insertions(+), 19 deletions(-)

diff --git a/flake.nix b/flake.nix @@ -131,7 +131,7 @@ inherit ((builtins.fromJSON (builtins.readFile ./package.json))) version; inherit src; - npmDepsHash = "sha256-c6rRnzceItLPD/AdylbO8kyA9AqBHYrcV+s9vTB8z68="; + npmDepsHash = "sha256-eqRRymOtj92bzH0yHCrvq72ATwPEwB4bfqpmu+5HHmE="; npmRebuildFlags = ["--ignore-scripts"]; nativeBuildInputs = with pkgs; [ diff --git a/package-lock.json b/package-lock.json @@ -19,6 +19,7 @@ "elm": "^0.19.1", "esbuild": "^0.28.0", "purescript": "^0.15.16", + "purescript-language-server": "^0.18.5", "purescript-psa": "^0.9.0", "purs-backend-es": "^1.4.3", "purs-tidy": "^0.11.1", @@ -5493,6 +5494,42 @@ "which": "bin/which" } }, + "node_modules/purescript-language-server": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/purescript-language-server/-/purescript-language-server-0.18.5.tgz", + "integrity": "sha512-taYQdwg9ZDV4foF0o+GDQ+oaoUyThqoufsnlRvZjhGHNmU+M2vGjkG2gpBbxiSVKFA58wqWy9WZf5MEE83TzZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shell-quote": "^1.7.2", + "uuid": "^9.0.0", + "vscode-jsonrpc": "^8.0.0-next.2", + "vscode-languageserver": "^8.0.0-next.2", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-uri": "^2.1.1", + "which": "^2.0.2" + }, + "bin": { + "purescript-language-server": "server.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/purescript-language-server/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/purescript-psa": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/purescript-psa/-/purescript-psa-0.9.0.tgz", @@ -5838,6 +5875,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -6553,6 +6603,71 @@ "dev": true, "license": "MIT" }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-8.1.0.tgz", + "integrity": "sha512-eUt8f1z2N2IEUDBsKaNapkz7jl5QpskN2Y0G01T/ItMxBxw1fJwvtySGB9QMecatne8jFIWJGWI61dWjyTLQsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.3" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.3.tgz", + "integrity": "sha512-924/h0AqsMtA5yK22GgMtCYiMdCOtWTSGgUOkgEDX+wk2b0x4sAfLiO4NxBxqbiVtz7K7/1/RgVrVI0NClZwqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.1.0", + "vscode-languageserver-types": "3.17.3" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0.tgz", + "integrity": "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz", + "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", + "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==", + "dev": true, + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json @@ -14,6 +14,7 @@ "elm": "^0.19.1", "esbuild": "^0.28.0", "purescript": "^0.15.16", + "purescript-language-server": "^0.18.5", "purescript-psa": "^0.9.0", "purs-backend-es": "^1.4.3", "purs-tidy": "^0.11.1", diff --git a/src/Command.js b/src/Command.js @@ -0,0 +1 @@ +export const prettyStringify = (json) => JSON.stringify(json, null, 2) + "\n"; diff --git a/src/Command.purs b/src/Command.purs @@ -0,0 +1,140 @@ +module Command where + +import Prelude + +import Control.Monad.Error.Class (throwError) +import Data.Argonaut (parseJson) +import Data.Argonaut.Core (Json, fromArray, fromBoolean, fromNumber, fromObject, fromString, toArray, toObject, toString) +import Data.Array (catMaybes, find, snoc) +import Data.Array as Array +import Data.Either (Either(..)) +import Data.Foldable (for_) +import Data.Maybe (Maybe(..), fromMaybe) +import Data.Tuple (Tuple(..)) +import Db as Db +import Effect.Aff (Aff) +import Effect.Class (liftEffect) +import Effect.Console (log) +import Effect.Exception (error) +import Foreign.Object as Object +import Node.Encoding (Encoding(UTF8)) +import Node.FS.Aff as FSA +import Node.Process (cwd, exit, lookupEnv) +import Unsafe.Coerce (unsafeCoerce) + +foreign import prettyStringify :: Json -> String + +getFlag :: String -> Array String -> Maybe String +getFlag flag args = case Array.uncons args of + Nothing -> Nothing + Just { head: k, tail: rest } -> + if k == flag then map _.head (Array.uncons rest) + else getFlag flag rest + +resolvePath :: String -> Maybe String -> String -> String +resolvePath defaultDir mDbPath file = case mDbPath of + Nothing -> defaultDir <> "/" <> file + Just dir -> dir <> "/" <> file + +encodeUserEntry + :: { slug :: String, name :: Maybe String, dbFile :: String, lbUser :: Maybe String, lfUser :: Maybe String } + -> Json +encodeUserEntry e = fromObject $ Object.fromFoldable $ catMaybes + [ Just $ Tuple "slug" (fromString e.slug) + , map (\n -> Tuple "name" (fromString n)) e.name + , Just $ Tuple "config" $ fromObject $ Object.fromFoldable $ catMaybes + [ Just $ Tuple "databaseFile" (fromString e.dbFile) + , map (\u -> Tuple "listenbrainzUser" (fromString u)) e.lbUser + , map (\u -> Tuple "lastfmUser" (fromString u)) e.lfUser + , Just $ Tuple "coverCacheEnabled" (fromBoolean false) + , Just $ Tuple "backupEnabled" (fromBoolean false) + , Just $ Tuple "backupIntervalHours" (fromNumber 24.0) + ] + ] + +readUsersJson :: String -> Aff (Array Json) +readUsersJson configFile = do + raw <- FSA.readTextFile UTF8 configFile + json <- case parseJson raw of + Left e -> throwError (error $ "Failed to parse " <> configFile <> ": " <> show e) + Right j -> pure j + case toObject json >>= Object.lookup "users" >>= toArray of + Nothing -> throwError (error "Invalid users.json: expected { users: [...] }") + Just arr -> pure arr + +writeUsersJson :: String -> Array Json -> Aff Unit +writeUsersJson configFile users = + FSA.writeTextFile UTF8 configFile + $ prettyStringify + $ fromObject + $ Object.fromFoldable [ Tuple "users" (fromArray users) ] + +run :: String -> Array String -> Aff Unit +run configFile args = case Array.uncons args of + Just { head: "add-user", tail: rest } -> addUser configFile rest + Just { head: "reset-token", tail: rest } -> resetToken configFile rest + Just { head: "list-users" } -> listUsers configFile + _ -> liftEffect do + log "Usage:" + log " add-user --slug <slug> --db <file> [--name <name>] [--listenbrainz-user <user>] [--lastfm-user <user>]" + log " reset-token --slug <slug>" + log " list-users" + exit 1 + +addUser :: String -> Array String -> Aff Unit +addUser configFile args = do + let + mSlug = getFlag "--slug" args + mDb = getFlag "--db" args + mName = getFlag "--name" args + mLbUser = getFlag "--listenbrainz-user" args + mLfUser = getFlag "--lastfm-user" args + case mSlug, mDb of + Nothing, _ -> liftEffect $ log "Error: --slug is required" *> exit 1 + _, Nothing -> liftEffect $ log "Error: --db is required" *> exit 1 + Just slug, Just dbFile -> do + users <- readUsersJson configFile + case find (\u -> (toObject u >>= Object.lookup "slug" >>= toString) == Just slug) users of + Just _ -> liftEffect $ log ("Error: user '" <> slug <> "' already exists") *> exit 1 + Nothing -> do + let newEntry = encodeUserEntry { slug, name: mName, dbFile, lbUser: mLbUser, lfUser: mLfUser } + writeUsersJson configFile (snoc users newEntry) + conn <- Db.connect dbFile + Db.initDb conn + Db.initReleaseMetadata conn + mToken <- Db.getOrCreateToken conn slug + liftEffect $ for_ mToken \token -> + log $ "Created user '" <> slug <> "'. API token: " <> token + +resetToken :: String -> Array String -> Aff Unit +resetToken configFile args = do + let mSlug = getFlag "--slug" args + case mSlug of + Nothing -> liftEffect $ log "Error: --slug is required" *> exit 1 + Just slug -> do + users <- readUsersJson configFile + case find (\u -> (toObject u >>= Object.lookup "slug" >>= toString) == Just slug) users of + Nothing -> liftEffect $ log ("Error: user '" <> slug <> "' not found") *> exit 1 + Just userJson -> do + let mDbFile = toObject userJson >>= Object.lookup "config" >>= toObject >>= Object.lookup "databaseFile" >>= toString + case mDbFile of + Nothing -> throwError (error $ "Cannot read databaseFile for user '" <> slug <> "'") + Just dbFile -> do + defaultDir <- liftEffect cwd + mDbPath <- liftEffect $ lookupEnv "DATABASE_PATH" + let fullPath = resolvePath defaultDir mDbPath dbFile + conn <- Db.connect fullPath + Db.run conn "DELETE FROM api_tokens WHERE slug = ?" [ unsafeCoerce slug ] + mToken <- Db.getOrCreateToken conn slug + liftEffect $ for_ mToken \token -> + log $ "New token for '" <> slug <> "': " <> token + +listUsers :: String -> Aff Unit +listUsers configFile = do + users <- readUsersJson configFile + liftEffect $ for_ users \u -> do + let slug = fromMaybe "(root)" (toObject u >>= Object.lookup "slug" >>= toString) + let mName = toObject u >>= Object.lookup "name" >>= toString + log $ case mName of + Just name -> slug <> " (" <> name <> ")" + Nothing -> slug diff --git a/src/Main.purs b/src/Main.purs @@ -36,13 +36,14 @@ import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) import Node.HTTP.Server as Server import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) import Node.Net.Server (listenTcp, listeningH) -import Node.Process (lookupEnv) +import Node.Process (argv, lookupEnv) import Node.Stream (end, write, writeString) import Node.Stream.Aff (readableToStringUtf8) import Sync (lbSync, lbSyncLoop, lfSync, lfSyncLoop) import Web.URL (URL) import Web.URL as URL import Web.URL.URLSearchParams as URLSearchParams +import Command as Command import Templates (indexHtml) import Types (Listen(..), ListenBrainzAdditionalInfo(..), ListenBrainzSubmitListen(..), ListenBrainzSubmitPayload(..), ListenBrainzSubmitTrackMetadata(..), MbidMapping(..), TrackMetadata(..)) import Foreign.Object as Object @@ -436,20 +437,25 @@ main = do dotenvConfig launchAff_ do configFile <- liftEffect $ map (fromMaybe "users.json") $ lookupEnv "CORPUS_USERS_FILE" - result <- try $ loadConfig configFile - case result of - Left err -> do - Log.error $ "Failed to load " <> configFile <> ": " <> Exception.message err - liftEffect $ Exception.throwException err - Right (appConfig :: AppConfig) -> do - Log.info $ "Loaded " <> show (length appConfig.users) <> " user(s) from " <> configFile - contexts <- traverse startUser appConfig.users - liftEffect $ do - server <- createServer - server # on_ Server.requestH (handleRequest appConfig.metricsEnabled contexts) - let netServer = Server.toNetServer server - - netServer # on_ listeningH do - Log.info $ "Server is running on " <> appConfig.host <> ":" <> show appConfig.port - - listenTcp netServer { host: appConfig.host, port: appConfig.port, backlog: 128 } + args <- liftEffect argv + let commandArgs = Data.Array.drop 2 args + case Data.Array.uncons commandArgs of + Just _ -> Command.run configFile commandArgs + Nothing -> do + result <- try $ loadConfig configFile + case result of + Left err -> do + Log.error $ "Failed to load " <> configFile <> ": " <> Exception.message err + liftEffect $ Exception.throwException err + Right (appConfig :: AppConfig) -> do + Log.info $ "Loaded " <> show (length appConfig.users) <> " user(s) from " <> configFile + contexts <- traverse startUser appConfig.users + liftEffect $ do + server <- createServer + server # on_ Server.requestH (handleRequest appConfig.metricsEnabled contexts) + let netServer = Server.toNetServer server + + netServer # on_ listeningH do + Log.info $ "Server is running on " <> appConfig.host <> ":" <> show appConfig.port + + listenTcp netServer { host: appConfig.host, port: appConfig.port, backlog: 128 }