From b9e84691c1bc68edb09124b025b8853d130ba604 Mon Sep 17 00:00:00 2001 From: Phillip Johnsen Date: Sat, 28 Dec 2024 01:52:13 +0100 Subject: [PATCH 1/5] Rewrite from ReasonML to TypeScript These changes are inspired by the fact that ReasonML was a very interesting learning experience years back and was a cool challenge, but as time as passed, the ReasonML aspect of this tool has severily held back external contributions. Since the initial creator has stopped using GitLab the recent years, it's even more important for this project to be approachable by new collaborators, where TypeScript certainly opens up the possibilities a lot more. Lots of help from our new AI companions, the .ts files are meant to be a direct translation of the old .re variants. Few nitpicky changes has been done after initial AI translation, let's see if it works in practice or not. --- src/Commander.re | 125 ------------------- src/Config.re | 128 ------------------- src/GitLab.re | 317 ----------------------------------------------- src/Main.re | 117 ----------------- src/Print.re | 52 -------- src/config.ts | 112 +++++++++++++++++ src/gitlab.ts | 242 ++++++++++++++++++++++++++++++++++++ src/main.ts | 118 ++++++++++++++++++ src/print.ts | 68 ++++++++++ 9 files changed, 540 insertions(+), 739 deletions(-) delete mode 100644 src/Commander.re delete mode 100644 src/Config.re delete mode 100644 src/GitLab.re delete mode 100644 src/Main.re delete mode 100644 src/Print.re create mode 100644 src/config.ts create mode 100644 src/gitlab.ts create mode 100644 src/main.ts create mode 100644 src/print.ts diff --git a/src/Commander.re b/src/Commander.re deleted file mode 100644 index d5195a6..0000000 --- a/src/Commander.re +++ /dev/null @@ -1,125 +0,0 @@ -/** - * This internal ReasonML module is used to wrap the 3rd party commander.js package - * and mostly consists of interop with JavaScript via bucklescript bindings. - * - * Useful resources to learn about ReasonML/bucklescript's interop with JavaScript: - * - https://medium.com/@Hehk/binding-a-library-in-reasonml-e33b6a58b1b3 - * - https://github.com/glennsl/bucklescript-ffi-cheatsheet - * - https://bucklescript.github.io/bucklescript/Manual.html#_binding_to_a_value_from_a_module_code_bs_module_code - */ - -/* t == commander.js being used for commander during its execution */ -type t; -type actionFnOptions; - -/* this basically does a require("commander") behind the scenes and assigns the result to the type t */ -[@bs.module] external commander: t = "commander"; - -/* an idiomatic make() function to get a hold of commander.js */ -let make = () => commander; - -[@bs.send.pipe: t] -external action: (unit /* in reality variadic arguments */ => unit) => t = - "action"; - -/** - This overrides the Commander.action() function for users of this module, needed because - commander.js invokes the callback provided with variadic (read dynamic) arguments when invoking it. - That doesn't play well with a strongly typed language like ReasonML, where every argument into a - function has to be explicitly declared. - - Therefore doing some raw bucklescript tricks here, to convert all the dynamic arguments provided by - commander.js into *one* array of strings before we invoke the callback provided by the user of this - Commander ReasonML module. - - Worth mentioning [@bs.variadic] does something similar, but as far as I've understood that's only - viable for method calls done from ReasonML / our application code. This is the other way around though, - as this is for making arguments provided *from* JavaScript to ReasonML. - */ -let action: ((array(string), actionFnOptions) => unit, t) => t = - (fn, t) => { - // action() below means the external action() definition above, in other words we're not calling - // ourselfs in a recursive manner here - action( - () => { - // Handling arguments of a callback invoked by JavaScript with variadic arguments isn't - // straight forward to handling in ReasonML/OCaml's type system. The trick we do below is - // to grab all the arguments provided by JavaScript, extract the latest argument and treat it - // as "options", then put all preceding arguments into a string array - let arguments = [%bs.raw {| arguments |}]; - let argumentsCount = Js.Array.length(arguments); - let options: actionFnOptions = - Js.Array.unsafe_get(arguments, argumentsCount - 1); - let allArgsExceptLast: array(string) = - Belt.Array.slice(arguments, ~offset=0, ~len=argumentsCount - 1); - - // this it what ends up invoking the callback provided by the code using Commander.action() - fn(allArgsExceptLast, options); - }, - t, - ); - }; - -// bs.get gets a specific field from a JavaScript object -[@bs.get] external getArgs: t => array(string) = "args"; -// bs.get_index gets a user specified field from a JavaScript object, string argument decides which field to get -[@bs.get_index] -external getOption: (actionFnOptions, string) => option(string) = ""; - -[@bs.get_index] -external getOptionAsInt: (actionFnOptions, string) => option(int) = ""; - -[@bs.get_index] -external getOptionAsBoolean: (actionFnOptions, string) => option(bool) = ""; - -/** - This overrides the Commander.getOptionAsBoolean() function for users of this module because commander.js does - return an *actual* boolean value when an option exist that doesn't have an argument. That will make commander.js - return the CLI option's value as a boolean, rather than a string as it does for other options that accepts an argument. - - Since returning an optional value wrapping a boolean to users of this module, feels a bit clunky, it converts that - optional value that's returned from commander.js into an concrete bool value instead. -*/ -let getOptionAsBoolean = (actionFnOptions, optionName): bool => { - let maybeBoolValue = getOptionAsBoolean(actionFnOptions, optionName); - - Belt.Option.getWithDefault(maybeBoolValue, false); -}; - -[@bs.send.pipe: t] external arguments: string => t = "arguments"; -[@bs.send.pipe: t] external command: string => t = "command"; -[@bs.send.pipe: t] external description: string => t = "description"; -[@bs.send.pipe: t] external help: unit = "help"; -[@bs.send.pipe: t] external option: (string, string) => t = "option"; -[@bs.send.pipe: t] -external optionWithDefault: (string, string, string) => t = "option"; -[@bs.send.pipe: t] external parse: array(string) => t = "parse"; -[@bs.send.pipe: t] external version: string => t = "version"; - -// commander.js' .option() also accepts a validator-function provided as the third argument, -// if so, the forth argument is the default value, not the third as usual -[@bs.send.pipe: t] -external optionWithIntDefault: (string, string, string => int, int) => t = - "option"; - -let optionWithIntDefault = (name, description, defaultValue) => { - optionWithIntDefault( - name, - description, - valueProvidedByEndUser => - try (int_of_string(valueProvidedByEndUser)) { - | Failure(_) => - Js.log( - "Invalid number value (" - ++ valueProvidedByEndUser - ++ ") provided to " - ++ name - ++ ", will be using " - ++ string_of_int(defaultValue) - ++ " instead.", - ); - defaultValue; - }, - defaultValue, - ); -}; diff --git a/src/Config.re b/src/Config.re deleted file mode 100644 index 05ffe31..0000000 --- a/src/Config.re +++ /dev/null @@ -1,128 +0,0 @@ -open Belt; - -module Protocol = { - type t = - | HTTP - | HTTPS; - - let toString = protocol => - switch (protocol) { - | HTTPS => "https" - | HTTP => "http" - }; - - let fromString = protocol => { - switch (protocol) { - | "http" => HTTP - | _ => HTTPS - }; - }; -}; - -type t = { - domain: string, - token: string, - ignoreSSL: bool, - protocol: Protocol.t, - concurrency: int, -}; - -// As the type below is passed to JavaScript as a configuration object, -// it can't be a native ReasonML type, but rather a derived type so that -// the bucklescript compiler creates a proper JavaScript object of it -// with field names set as expected etc -- native ReasonML record types -// won't work because they're internally made with JavaScript arrays -[@bs.deriving abstract] -type serialisedConfig = { - [@bs.optional] - domain: string, - [@bs.optional] - token: string, - [@bs.optional] - config: string, - [@bs.optional] - ignoreSSL: bool, - [@bs.optional] - concurrency: int, -}; - -// https://www.npmjs.com/package/rc -[@bs.module] external rc: string => serialisedConfig = "rc"; - -let defaultDomain = "gitlab.com"; -let defaultDirectory = "."; -let defaultConcurrency = 25; -let defaultArchive = "all"; - -let parseProtocolAndDomain = rootApiUriOrOnlyDomain => { - let splitOnScheme = - rootApiUriOrOnlyDomain |> Js.String.toLowerCase |> Js.String.split("://"); - - switch (splitOnScheme) { - | [|protocol, domain|] => (Protocol.fromString(protocol), domain) - | [|domain|] => (Protocol.HTTPS, domain) - | [||] => (Protocol.HTTPS, defaultDomain) - | _ => - raise( - Js.Exn.raiseError( - "Configured API domain does not look like a valid domain or root GitLab API URI, please double check your configuration of: " - ++ rootApiUriOrOnlyDomain, - ), - ) - }; -}; - -let loadFromFile = (): Belt.Result.t(t, string) => { - let result = rc("gitlabsearch"); - let ignoreSSL = Option.getWithDefault(ignoreSSLGet(result), false); - let concurrency = - Option.getWithDefault(concurrencyGet(result), defaultConcurrency); - let (protocol, domain) = - domainGet(result) - ->Option.getWithDefault(defaultDomain) - ->parseProtocolAndDomain; - - switch (configGet(result)) { - | Some(configPath) => - Option.mapWithDefault( - tokenGet(result), - Result.Error( - "No personal access token was found in " - ++ configPath - ++ ", please run setup again!", - ), - token => - Result.Ok({domain, concurrency, token, ignoreSSL, protocol}) - ) - | None => - Result.Error( - "Could not find .gitlabsearchrc configuration file anywhere, have you run setup yet?", - ) - }; -}; - -let writeToFile = - ( - ~domainOrRootUri: string, - ~ignoreSSL: bool, - ~token: string, - ~directory, - ~concurrency, - ) => { - let filePath = Node.Path.join2(directory, ".gitlabsearchrc"); - let domain = - domainOrRootUri == defaultDomain ? None : Some(domainOrRootUri); - let ignoreSSL = ignoreSSL ? Some(true) : None; - let concurrency = - concurrency == defaultConcurrency ? None : Some(concurrency); - let content = - serialisedConfig(~domain?, ~ignoreSSL?, ~token, ~concurrency?, ()); - - Node.Fs.writeFileSync( - filePath, - Js.Option.getExn(Js.Json.stringifyAny(content)), - `utf8, - ); - - filePath; -}; diff --git a/src/GitLab.re b/src/GitLab.re deleted file mode 100644 index 858b0cc..0000000 --- a/src/GitLab.re +++ /dev/null @@ -1,317 +0,0 @@ -open Belt; - -[@bs.val] external debugEnv: Js.Nullable.t(string) = "process.env.DEBUG"; - -type group = { - id: string, - name: string, -}; - -type project = { - id: int, - name: string, - web_url: string, - archived: bool, -}; - -type searchFilter = - | Filename(option(string)) - | Extension(option(string)) - | Path(option(string)); - -type searchCriterias = { - term: string, - filters: array(searchFilter), -}; - -type searchResult = { - data: string, - filename: string, - ref: string, - startline: int, -}; - -module Decode = { - open Json.Decode; - - let group = json => { - id: json |> field("id", int) |> string_of_int, - name: json |> field("name", string), - }; - let groups = json => json |> array(group); - - let project = json => { - id: json |> field("id", int), - name: json |> field("name", string), - web_url: json |> field("web_url", string), - archived: json |> field("archived", bool), - }; - let projects = json => json |> array(project); - - let searchResult = json => { - data: json |> field("data", string), - filename: json |> field("filename", string), - ref: json |> field("ref", string), - startline: json |> field("startline", int), - }; - let searchResults = (project, json) => ( - project, - array(searchResult, json), - ); -}; - -let configResult = Config.loadFromFile(); - -let httpsAgent = - switch (configResult) { - | Belt.Result.Ok(config) => - switch (config.protocol) { - | HTTP => None - | HTTPS => - Axios.Agent.Https.config( - ~rejectUnauthorized=!config.ignoreSSL, - ~maxSockets=config.concurrency, - (), - ) - ->Axios.Agent.Https.create - ->Some - } - | Belt.Result.Error(_) => None - }; - -let debugLog = (text): unit => { - let isDebugEnabled = !Js.Nullable.isNullable(debugEnv); - - if (isDebugEnabled) { - Js.log(text); - }; -}; - -let request = (relativeUrl, decoder) => { - let config = - switch (configResult) { - | Belt.Result.Ok(value) => value - | Belt.Result.Error(failureReason) => - raise(Js.Exn.raiseError(failureReason)) - }; - - let headers = Axios.Headers.fromObj({"Private-Token": config.token}); - let options = Axios.makeConfig(~headers, ~httpsAgent?, ()); - let scheme = Config.Protocol.toString(config.protocol) ++ "://"; - let url = scheme ++ config.domain ++ "/api/v4" ++ relativeUrl; - - debugLog("Requesting: GET " ++ url); - - Js.Promise.( - Axios.getc(url, options) - |> then_(response => resolve(response##data)) - |> then_(json => resolve(decoder(json))) - ); -}; - -// Helpful when parsing hypermedia Link header values while paginating where URLs will be provided like: -// -let urlWithoutAngleBrackets = url => - Js.String.substring(~from=1, ~to_=Js.String.length(url) - 1, url); - -let getNextPaginationUrl = response => { - // Example from the docs: - // link: ; rel="prev", ; rel="next", ; rel="first", ; rel="last" - // - // Refs https://docs.gitlab.com/ee/api/README.html#pagination - let linkHeader: option(string) = response##headers##link; - - let nextLinkUrl = - Option.flatMap( - linkHeader, - header => { - let linkEntries = Js.String.split(",", header); - let links = - Array.map( - linkEntries, - linkEntry => { - let parts = - Js.String.split(";", linkEntry)->Array.map(Js.String.trim); - - let linkUrl = urlWithoutAngleBrackets(Array.getExn(parts, 0)); - let linkRel = Array.getExn(parts, 1); - - (linkUrl, linkRel); - }, - ); - - links - ->Array.keepMap(link => { - let (url, rel) = link; - - rel == "rel=\"next\"" ? Some(url) : None; - }) - ->Array.get(0); - }, - ); - - nextLinkUrl; -}; - -type requestUrl = - | RelativeUrl(string) // provided initially when kicking off a paginated request - | AbsoluteUrl(string); // provided when more pages of results has to be fetched when paginating - -let rec paginatedRequest = (url: requestUrl, decoder: Js.Json.t => array('a)) => { - let config = - switch (configResult) { - | Belt.Result.Ok(value) => value - | Belt.Result.Error(failureReason) => - raise(Js.Exn.raiseError(failureReason)) - }; - - let headers = Axios.Headers.fromObj({"Private-Token": config.token}); - let options = Axios.makeConfig(~headers, ~httpsAgent?, ()); - let scheme = Config.Protocol.toString(config.protocol) ++ "://"; - let urlToRequest = - switch (url) { - | RelativeUrl(path) => scheme ++ config.domain ++ "/api/v4" ++ path - | AbsoluteUrl(url) => url - }; - - debugLog("Requesting: GET " ++ urlToRequest); - - Js.Promise.( - Axios.getc(urlToRequest, options) - |> then_(response => { - let nextUrl = getNextPaginationUrl(response); - let json = response##data; - let entities = decoder(json); - - switch (nextUrl) { - | Some(url) => - paginatedRequest(AbsoluteUrl(url), decoder) - |> then_(entitesOnNextPage => - resolve(Array.concat(entities, entitesOnNextPage)) - ) - - | None => resolve(entities) - }; - }) - ); -}; - -let groupsFromStringNames = namesAsString => { - let names = Js.String.split(",", namesAsString); - let groups = Array.map(names, name => {id: name, name}); - - Js.Promise.resolve(groups); -}; - -// https://docs.gitlab.com/ee/api/groups.html#list-groups -let fetchGroups = (groupsNames: option(string)) => { - let groupsResult = - switch (groupsNames) { - | Some(names) => groupsFromStringNames(names) - | None => - paginatedRequest(RelativeUrl("/groups?per_page=100"), Decode.groups) - }; - - Js.Promise.( - groupsResult - |> then_(groups => { - let resolvedNames = - Array.map(groups, (group: group) => group.name) - |> Js.Array.joinWith(", "); - - debugLog("Using groups: " ++ resolvedNames); - - resolve(groups); - }) - ); -}; - -// https://docs.gitlab.com/ee/api/groups.html#list-a-groups-projects -let fetchProjectsInGroups = (archiveArgument: option(string), groups: array(group)) => { - let archiveQueryParam = switch (archiveArgument) { - | Some("only") => "&archived=true"; - | Some("exclude") => "&archived=false"; - | _ => ""; - }; - let requests = - Array.map( - groups, - // Very surprised this had to be annotated to be a group, cause or else it would be - // inferred as a project -- why on earth would that happen when the compiler gets very - // explicit information about the incoming function argument is a list of groups - (group: group) => - paginatedRequest( - RelativeUrl("/groups/" ++ group.id ++ "/projects?per_page=100" ++ archiveQueryParam), - Decode.projects, - ) - ); - - // this list <-> array is quite a pain in the backside, but don't have much choice - // since Promise.all() takes an array and list is the structure that has built-in flattening - Js.Promise.( - all(requests) - // concatMany == what usually is called flatten - |> then_(projects => resolve(Array.concatMany(projects))) - |> then_(allProjects => { - let resolvedNames = - Array.map(allProjects, (project: project) => project.name) - |> Js.Array.joinWith(", "); - - debugLog("Using projects: " ++ resolvedNames); - - resolve(allProjects); - }) - ); -}; - -let searchUrlParameter = (criterias: searchCriterias): string => { - let filters = - Array.( - criterias.filters - ->map(filter => - switch (filter) { - | Filename(value) => ("filename", value) - | Extension(value) => ("extension", value) - | Path(value) => ("path", value) - } - ) - ->keepMap(((parameterName, optionalValue)) => - Option.map(optionalValue, value => - parameterName ++ ":" ++ Js.Global.encodeURIComponent(value) - ) - ) - ); - - "&search=" - ++ Js.Global.encodeURIComponent(criterias.term) - ++ " " - ++ Js.Array.joinWith(" ", filters); -}; - -// https://docs.gitlab.com/ee/api/search.html#scope-blobs-2 -let searchInProjects = - (criterias: searchCriterias, projects: array(project)) - : Js.Promise.t(array((project, array(searchResult)))) => { - let requests = - Array.map(projects, project => - request( - "/projects/" - ++ string_of_int(project.id) - ++ "/search?scope=blobs" - ++ searchUrlParameter(criterias), - Decode.searchResults(project), - ) - ); - - Js.Promise.( - all(requests) - |> then_(results => - resolve( - // keep == filter which is only available on List for some reason - Array.keep(results, ((_, searchResults)) => - Array.length(searchResults) > 0 - ), - ) - ) - ); -}; diff --git a/src/Main.re b/src/Main.re deleted file mode 100644 index 7e70259..0000000 --- a/src/Main.re +++ /dev/null @@ -1,117 +0,0 @@ -let packageJson = [%bs.raw {| require("../../../package.json") |}]; - -let program = Commander.(make() |> version(packageJson##version)); - -let main = (args, options) => { - let getOption = optionName => Commander.getOption(options, optionName); - let groups = getOption("groups"); - let criterias = - GitLab.{ - // daring to do an unsafe get operation below because commander.js *should* have - // ensured the search term argument is available before invoking this function - term: Belt.Array.getUnsafe(args, 0), - filters: [| - Filename(getOption("filename")), - Extension(getOption("extension")), - Path(getOption("path")), - |], - }; - - Js.Promise.( - GitLab.fetchGroups(groups) - |> then_(GitLab.fetchProjectsInGroups(getOption("archive"))) - |> then_(GitLab.searchInProjects(criterias)) - |> then_(results => - resolve(Print.searchResults(criterias.term, results)) - ) - |> catch(err => resolve(Js.log2("Something exploded!", err))) - |> ignore - ); -}; - -let setup = (args, options) => { - // token is said to be a required argument so commander.js ensures it's present before executing this function - let token = Belt.Array.getExn(args, 0); - - // options below has default values set in their definition so they always has a value - let directory = Belt.Option.getExn(Commander.getOption(options, "dir")); - let domainOrRootUri = - Belt.Option.getExn(Commander.getOption(options, "apiDomain")); - let concurrency = - Belt.Option.getExn(Commander.getOptionAsInt(options, "concurrency")); - let ignoreSSL = Commander.getOptionAsBoolean(options, "ignoreSsl"); - - let configPath = - Config.writeToFile( - ~domainOrRootUri, - ~ignoreSSL, - ~token, - ~directory, - ~concurrency, - ); - Print.successful( - "Successfully wrote config to " - ++ configPath - ++ ", gitlab-search is now ready to be used", - ); -}; - -Commander.( - program - |> arguments("") - |> option( - "-g, --groups ", - "group(s) to find repositories in (separated with comma)", - ) - |> option( - "-f, --filename ", - "only search for contents in given a file, glob matching with wildcards (*)", - ) - |> option( - "-e, --extension ", - "only search for contents in files with given extension", - ) - |> option("-p, --path ", "only search in files in the given path") - |> optionWithDefault( - "-a, --archive [all,only,exclude]", - "to only search on archived repositories, or to exclude them, by default the search will be apply to all repositories", - Config.defaultArchive, - ) - |> action(main) -); - -Commander.( - program - |> command("setup") - |> description("create configuration file") - |> arguments("") - |> option( - "--ignore-ssl", - "ignore invalid SSL certificate from the GitLab API server", - ) - |> optionWithDefault( - "--api-domain ", - "domain name or root URL of GitLab API server,\nspecify root URL (without trailing slash) to use HTTP instead of HTTPS", - Config.defaultDomain, - ) - |> optionWithDefault( - "--dir ", - "path to directory to save configuration file in", - Config.defaultDirectory, - ) - |> optionWithIntDefault( - "--concurrency ", - "limit the amount of concurrent HTTPS requests sent to GitLab when searching,\nuseful when *many* projects are hosted on a small GitLab instance\nto avoid overwhelming the instance resulting in 502 errors", - Config.defaultConcurrency, - ) - |> action(setup) -); - -Commander.parse(Node.Process.argv, program); - -// commander.js doesn't display help when no arguments are provided by default, -// so we've gotta do that check ourselfs -let args = Commander.getArgs(program); -if (Array.length(args) == 0) { - Commander.help(program); -}; diff --git a/src/Print.re b/src/Print.re deleted file mode 100644 index aa0f806..0000000 --- a/src/Print.re +++ /dev/null @@ -1,52 +0,0 @@ -open Chalk; -open Belt; - -let urlToLineInFile = (project: GitLab.project, result: GitLab.searchResult) => { - project.web_url - ++ "/blob/" - ++ result.ref - ++ "/" - ++ result.filename - ++ "#L" - ++ string_of_int(result.startline); -}; - -let indentPreview = preview => - Js.String.replaceByRe([%re "/\\n/g"], "\n\t\t", preview); - -let highlightMatchedTerm = (term, data) => - Js.String.replaceByRe( - Js.Re.fromStringWithFlags("(" ++ term ++ ")", ~flags="gi"), - // by using a regex catch group, we ensure to keep the original capitalization of - // the matched source code, rather than the search term entered by the end-user - red("$1"), - data, - ); - -let searchResults = - ( - term: string, - results: array((GitLab.project, array(GitLab.searchResult))), - ) => { - Array.forEach( - results, - result => { - let (project: GitLab.project, searchResults) = result; - let formattedResults = - Array.reduce(searchResults, "", (sum, current) => - sum - ++ "\n\t" - ++ underline(urlToLineInFile(project, current)) - ++ "\n\n\t\t" - ++ highlightMatchedTerm(term, indentPreview(current.data)) - ); - - let archivedInfo = project.archived ? bold(red(" (archived)")) : ""; - - Js.log(bold(green(project.name ++ archivedInfo ++ ":"))); - Js.log(formattedResults); - }, - ); -}; - -let successful = message => Js.log(green({js|✔|js}) ++ " " ++ message); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d2158ef --- /dev/null +++ b/src/config.ts @@ -0,0 +1,112 @@ +import * as fs from "fs"; +import * as path from "path"; +import rc from "rc"; + +// Protocol enumeration +enum Protocol { + HTTP = "http", + HTTPS = "https", +} + +function protocolFromString(protocol: string): Protocol { + switch (protocol.toLowerCase()) { + case "http": + return Protocol.HTTP; + case "https": + return Protocol.HTTPS; + default: + throw new Error(`Invalid protocol: ${protocol}`); + } +} + +type Config = { + domain: string; + token: string; + ignoreSSL: boolean; + protocol: Protocol; + concurrency: number; +}; + +// Serialized configuration type for file storage +type SerializedConfig = { + domain?: string; + token?: string; + ignoreSSL?: boolean; + concurrency?: number; +}; + +// Default values +const DEFAULT_DOMAIN = "gitlab.com"; +const DEFAULT_DIRECTORY = "."; +const DEFAULT_CONCURRENCY = 25; + +// Utility: Parse protocol and domain from a string +function parseProtocolAndDomain(rootApiUriOrOnlyDomain: string): { + protocol: Protocol; + domain: string; +} { + const parts = rootApiUriOrOnlyDomain.toLowerCase().split("://"); + + if (parts.length === 2) { + const [protocol, domain] = parts; + return { protocol: protocolFromString(protocol), domain }; + } + + if (parts.length === 1) { + return { protocol: Protocol.HTTPS, domain: parts[0] }; + } + + throw new Error( + `Configured API domain does not look like a valid domain or root GitLab API URI: ${rootApiUriOrOnlyDomain}` + ); +} + +function loadFromFile(): Config | never { + const result = rc("gitlabsearch"); + + const ignoreSSL = result.ignoreSSL ?? false; + const concurrency = result.concurrency ?? DEFAULT_CONCURRENCY; + + const { protocol, domain } = parseProtocolAndDomain( + result.domain ?? DEFAULT_DOMAIN + ); + + if (result.token) { + return { + domain, + token: result.token, + ignoreSSL, + protocol, + concurrency, + }; + } + + throw new Error( + `Could not find personal access token in the configuration. Have you run setup yet?` + ); +} + +function writeToFile( + domainOrRootUri: string, + ignoreSSL: boolean, + token: string, + directory: string = DEFAULT_DIRECTORY, + concurrency: number = DEFAULT_CONCURRENCY +): string { + const filePath = path.join(directory, ".gitlabsearchrc"); + + const serializedConfig: SerializedConfig = { + domain: domainOrRootUri !== DEFAULT_DOMAIN ? domainOrRootUri : undefined, + ignoreSSL: ignoreSSL || undefined, + token, + concurrency: concurrency !== DEFAULT_CONCURRENCY ? concurrency : undefined, + }; + + const content = JSON.stringify(serializedConfig, null, 2); + + fs.writeFileSync(filePath, content, "utf-8"); + + return filePath; +} + +export { Protocol, Config, loadFromFile, writeToFile }; diff --git a/src/gitlab.ts b/src/gitlab.ts new file mode 100644 index 0000000..8866fcf --- /dev/null +++ b/src/gitlab.ts @@ -0,0 +1,242 @@ +import * as https from "https"; +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import * as Config from "./config"; + +type Group = { + id: string; + name: string; +}; + +type Project = { + id: number; + name: string; + web_url: string; + archived: boolean; +}; + +type SearchFilter = + | { type: "Filename"; value?: string } + | { type: "Extension"; value?: string } + | { type: "Path"; value?: string }; + +type SearchCriterias = { + term: string; + filters: SearchFilter[]; +}; + +type SearchResult = { + data: string; + filename: string; + ref: string; + startline: number; +}; + +const DEBUG_ENV = process.env.DEBUG; +function debugLog(message: string): void { + if (DEBUG_ENV) { + console.log(message); + } +} + +// Configuration loading +let configResult: Config.Config | undefined; +let httpsAgent: https.Agent | undefined; + +// Initiates configuration needed for the GitLab client to work, +// will `throw` if the configuration is missing or invalid +function initGitLabClient(): void { + const cfg = Config.loadFromFile(); + configResult = cfg; + + if (cfg.protocol === Config.Protocol.HTTPS) { + httpsAgent = new https.Agent({ + rejectUnauthorized: !cfg.ignoreSSL, + maxSockets: cfg.concurrency, + }); + } +} + +async function request( + relativeUrl: string, + decoder: (json: any) => T +): Promise { + if (!configResult) { + throw new Error("Configuration is missing or invalid."); + } + + const headers = { "Private-Token": configResult.token }; + const scheme = + configResult.protocol === Config.Protocol.HTTPS ? "https://" : "http://"; + const url = `${scheme}${configResult.domain}/api/v4${relativeUrl}`; + + debugLog(`Requesting: GET ${url}`); + + const options: AxiosRequestConfig = { + headers, + httpsAgent, + }; + + const response = await axios.get(url, options); + return decoder(response.data); +} + +// Decode functions +const decodeGroup = (json: any): Group => ({ + id: json.id.toString(), + name: json.name, +}); + +const decodeGroups = (json: any): Group[] => json.map(decodeGroup); + +const decodeProject = (json: any): Project => ({ + id: json.id, + name: json.name, + web_url: json.web_url, + archived: json.archived, +}); + +const decodeProjects = (json: any): Project[] => json.map(decodeProject); + +const decodeSearchResult = (json: any): SearchResult => ({ + data: json.data, + filename: json.filename, + ref: json.ref, + startline: json.startline, +}); + +const decodeSearchResults = ( + project: Project, + json: any +): [Project, SearchResult[]] => [project, json.map(decodeSearchResult)]; + +async function paginatedRequest( + relativeUrl: string, + decoder: (json: any) => T[] +): Promise { + if (!configResult) { + throw new Error("Configuration is missing or invalid."); + } + + const url = + String(configResult.protocol) + + "://" + + configResult.domain + + "/api/v4" + + relativeUrl; + + let nextUrl: string | undefined = url; + let results: T[] = []; + + while (nextUrl) { + debugLog(`Requesting: GET ${nextUrl}`); + const response: AxiosResponse = await axios.get(nextUrl, { + headers: { "Private-Token": configResult.token }, + httpsAgent, + }); + + results = results.concat(decoder(response.data)); + nextUrl = getNextPaginationUrl(response); + } + + return results; +} + +function getNextPaginationUrl(response: AxiosResponse): string | undefined { + const linkHeader = response.headers.link; + if (!linkHeader) return undefined; + + const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + return matches ? matches[1] : undefined; +} + +// https://docs.gitlab.com/ee/api/groups.html#list-groups +async function fetchGroups(groupNames?: string): Promise { + if (groupNames) { + const names = groupNames.split(","); + return names.map((name) => ({ id: name, name })); + } + + return paginatedRequest("/groups?per_page=100", decodeGroups); +} + +// https://docs.gitlab.com/ee/api/groups.html#list-a-groups-projects +async function fetchProjectsInGroups( + archiveOption: string | undefined, + groups: Group[] +): Promise { + const archiveQueryParam = + archiveOption === "only" + ? "&archived=true" + : archiveOption === "exclude" + ? "&archived=false" + : ""; + + const projectRequests = groups.map((group) => + paginatedRequest( + `/groups/${group.id}/projects?per_page=100${archiveQueryParam}`, + decodeProjects + ) + ); + + const projectsArray = await Promise.all(projectRequests); + const allProjects = projectsArray.flat(); + + debugLog( + `Using projects: ${allProjects.map((project) => project.name).join(", ")}` + ); + return allProjects; +} + +function buildSearchUrlParams(criterias: SearchCriterias): string { + const filters = criterias.filters + .map((filter) => { + switch (filter.type) { + case "Filename": + return filter.value + ? `filename:${encodeURIComponent(filter.value)}` + : null; + case "Extension": + return filter.value + ? `extension:${encodeURIComponent(filter.value)}` + : null; + case "Path": + return filter.value + ? `path:${encodeURIComponent(filter.value)}` + : null; + } + }) + .filter(Boolean) + .join(" "); + + return `&search=${encodeURIComponent(criterias.term)} ${filters}`; +} + +// https://docs.gitlab.com/ee/api/search.html#scope-blobs-2 +async function searchInProjects( + criterias: SearchCriterias, + projects: Project[] +): Promise<[Project, SearchResult[]][]> { + const searchRequests = projects.map((project) => + request( + `/projects/${project.id}/search?scope=blobs${buildSearchUrlParams( + criterias + )}`, + (json) => decodeSearchResults(project, json) + ) + ); + + const results = await Promise.all(searchRequests); + return results.filter(([_, searchResults]) => searchResults.length > 0); +} + +export { + fetchGroups, + fetchProjectsInGroups, + initGitLabClient, + searchInProjects, + SearchCriterias, + SearchFilter, + SearchResult, + Group, + Project, +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9e0ce64 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,118 @@ +import { Command } from "commander"; +import { + fetchGroups, + fetchProjectsInGroups, + initGitLabClient, + SearchCriterias, + searchInProjects, +} from "./gitlab"; +import { writeToFile } from "./config"; +import { searchResults, successful } from "./print"; +import packageJson from "../package.json"; + +const program = new Command(); +program.version(packageJson.version); + +async function main(args: string[], options: Record) { + const groupsOpt = options.groups; + + const criterias: SearchCriterias = { + term: args[0], // Unsafe access assumed to be handled by Commander validation + filters: [ + { type: "Filename", value: options.filename }, + { type: "Extension", value: options.extension }, + { type: "Path", value: options.path }, + ], + }; + + try { + initGitLabClient(); + + const groups = await fetchGroups(groupsOpt); + const projectData = await fetchProjectsInGroups(options.archive, groups); + const results = await searchInProjects(criterias, projectData); + searchResults(criterias.term, results); + } catch (err) { + console.error("Something went wrong!", err); + } +} + +// Setup functionality for configuration +function setup(args: string, options: Record) { + const token = args; // Required argument validated by Commander + const directory = options.dir; + const domainOrRootUri = options.apiDomain; + const concurrency = options.concurrency; + const ignoreSSL = options.ignoreSsl === true; + + const configPath = writeToFile( + domainOrRootUri, + ignoreSSL, + token, + directory, + concurrency + ); + + successful( + `Successfully wrote config to ${configPath}, gitlab-search is now ready to be used.` + ); +} + +// Define the main search command +program + .arguments("") + .option( + "-g, --groups ", + "Group(s) to find repositories in (comma-separated)" + ) + .option( + "-f, --filename ", + "Only search for contents in a given file, supports glob matching with wildcards (*)" + ) + .option( + "-e, --extension ", + "Only search for contents in files with a given extension" + ) + .option("-p, --path ", "Only search in files in the given path") + .option( + "-a, --archive [all|only|exclude]", + "Search on archived repositories, exclude them, or apply to all (default: all)", + "all" // Default value for archive + ) + .action(main); + +// Define the setup command +program + .command("setup") + .description("Create configuration file") + .arguments("") + .option( + "--ignore-ssl", + "Ignore invalid SSL certificate from the GitLab API server" + ) + .option( + "--api-domain ", + "Domain name or root URL of GitLab API server.\nSpecify root URL (without trailing slash) to use HTTP instead of HTTPS", + "gitlab.com" // Default domain + ) + .option( + "--dir ", + "Path to the directory to save the configuration file in", + "." // Default directory + ) + .option( + "--concurrency ", + "Limit the number of concurrent HTTPS requests sent to GitLab when searching.\n" + + "Useful when many projects are hosted on a small GitLab instance to avoid overwhelming it, resulting in 502 errors", + parseInt, // Parse to integer + 25 // Default concurrency + ) + .action(setup); + +// Parse command-line arguments +program.parse(process.argv); + +// Display help if no arguments are provided +if (process.argv.length <= 2) { + program.help(); +} diff --git a/src/print.ts b/src/print.ts new file mode 100644 index 0000000..396201c --- /dev/null +++ b/src/print.ts @@ -0,0 +1,68 @@ +import chalk from "chalk"; + +// Types for GitLab data +type GitLabProject = { + name: string; + web_url: string; + archived: boolean; +}; + +type GitLabSearchResult = { + ref: string; + filename: string; + startline: number; + data: string; +}; + +// Function to create a URL pointing to a specific line in a file +function urlToLineInFile( + project: GitLabProject, + result: GitLabSearchResult +): string { + return `${project.web_url}/blob/${result.ref}/${result.filename}#L${result.startline}`; +} + +// Function to indent multiline previews +function indentPreview(preview: string): string { + return preview.replace(/\n/g, "\n\t\t"); +} + +// Function to highlight matched terms in search results +function highlightMatchedTerm(term: string, data: string): string { + const regex = new RegExp(`(${term})`, "gi"); + return data.replace(regex, chalk.red("$1")); +} + +// Function to display search results +function searchResults( + term: string, + results: Array<[GitLabProject, GitLabSearchResult[]]> +): void { + results.forEach(([project, searchResults]) => { + const formattedResults = searchResults.reduce((sum, current) => { + const resultUrl = urlToLineInFile(project, current); + const highlightedPreview = highlightMatchedTerm( + term, + indentPreview(current.data) + ); + + return ( + sum + `\n\t${chalk.underline(resultUrl)}\n\n\t\t${highlightedPreview}` + ); + }, ""); + + const archivedInfo = project.archived + ? chalk.bold(chalk.red(" (archived)")) + : ""; + + console.log(chalk.bold(chalk.green(`${project.name}${archivedInfo}:`))); + console.log(formattedResults); + }); +} + +// Function to display success messages +function successful(message: string): void { + console.log(`${chalk.green("✔")} ${message}`); +} + +export { searchResults, successful }; From 4b2afc689b72e8c75f7a765f23459752ccc4338b Mon Sep 17 00:00:00 2001 From: Phillip Johnsen Date: Sat, 28 Dec 2024 01:57:16 +0100 Subject: [PATCH 2/5] Refactor build process to work with TypeScript Out with ReasonML build tools, in with corresponding TypeScript tools. --- package-lock.json | 356 ++++++++++++++++++++++++++++------------------ package.json | 19 +-- tsconfig.json | 15 ++ 3 files changed, 244 insertions(+), 146 deletions(-) create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json index c3f45c0..9081dad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,268 +1,350 @@ { "name": "gitlab-search", "version": "1.5.0", - "lockfileVersion": 1, + "lockfileVersion": 3, "requires": true, - "dependencies": { - "@glennsl/bs-json": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@glennsl/bs-json/-/bs-json-5.0.2.tgz", - "integrity": "sha512-vVlHJNrhmwvhyea14YiV4L5pDLjqw1edE3GzvMxlbPPQZVhzgO3sTWrUxCpQd2gV+CkMfk4FHBYunx9nWtBoDg==", - "dev": true + "packages": { + "": { + "name": "gitlab-search", + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "axios": "^1.7.9", + "chalk": "^5.4.1" + }, + "bin": { + "gitlab-search": "bin/gitlab-search.js" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/rc": "^1.2.4", + "@vercel/ncc": "^0.38.3", + "commander": "^5.0.0", + "rc": "^1.2.8", + "rimraf": "^3.0.2", + "typescript": "^5.7.2" + } }, - "@zeit/ncc": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.22.0.tgz", - "integrity": "sha512-zaS6chwztGSLSEzsTJw9sLTYxQt57bPFBtsYlVtbqGvmDUsfW7xgXPYofzFa1kB9ur2dRop6IxCwPnWLBVCrbQ==", + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "dev": true, - "requires": { - "color-convert": "^1.9.0" + "dependencies": { + "undici-types": "~6.20.0" } }, - "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "node_modules/@types/rc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/rc/-/rc-1.2.4.tgz", + "integrity": "sha512-xD6+epQoMH79A1uwmJIq25D+XZ57jUzCQ1DGSvs3tGKdx7QDYOOaMh6m5KBkEIW4+Cy5++bZ7NLDfdpNiYVKYA==", "dev": true, - "requires": { - "follow-redirects": "1.5.10" + "dependencies": { + "@types/minimist": "*" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.3.tgz", + "integrity": "sha512-rnK6hJBS6mwc+Bkab+PGPs9OiS0i/3kdTO+CkI8V0/VrW3vmz7O2Pxjw/owOlmo6PKEIxRSeZKv/kuL9itnpYA==", + "dev": true, + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, - "balanced-match": { + "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, - "brace-expansion": { + "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "requires": { + "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "bs-axios": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/bs-axios/-/bs-axios-0.0.43.tgz", - "integrity": "sha512-TI+LZ3L4KurI/D6O60Ao04OYoknla7EJuCRBRQvWohxKO7ZMYThelYEEeChIVjjqZxukIVAag3JQ3+/WCJvwrg==", - "dev": true, - "requires": { - "axios": "^0.19.2" - } - }, - "bs-chalk": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/bs-chalk/-/bs-chalk-0.2.1.tgz", - "integrity": "sha512-wyy8l8CuZRUeKD2DHgiD1osOX6RdLKTwLuUazWhlaNqTvgQ3j6wTWaKpfGP+e5JhgF+vmKCreDMvFAWTSZFK4g==", - "dev": true, - "requires": { - "chalk": "^2.3.0" - } - }, - "bs-platform": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/bs-platform/-/bs-platform-5.2.1.tgz", - "integrity": "sha512-3ISP+RBC/NYILiJnphCY0W3RTYpQ11JGa2dBBLVug5fpFZ0qtSaL3ZplD8MyjNeXX2bC7xgrWfgBSn8Tc9om7Q==", - "dev": true - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" } }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "commander": { + "node_modules/commander": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.0.0.tgz", "integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==", - "dev": true + "dev": true, + "engines": { + "node": ">= 6" + } }, - "concat-map": { + "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "deep-extend": { + "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true + "dev": true, + "engines": { + "node": ">=4.0.0" + } }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } }, - "follow-redirects": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", - "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "dev": true, - "requires": { - "debug": "=3.1.0" + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "fs.realpath": { + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "glob": { + "node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "requires": { + "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "inflight": { + "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "requires": { + "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, - "inherits": { + "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "ini": { + "node_modules/ini": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", "dev": true }, - "minimatch": { + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, - "requires": { + "dependencies": { "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "minimist": { + "node_modules/minimist": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "once": { + "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "requires": { + "dependencies": { "wrappy": "1" } }, - "path-is-absolute": { + "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "rc": { + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, - "requires": { + "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" } }, - "rimraf": { + "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "requires": { + "dependencies": { "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "strip-json-comments": { + "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, - "requires": { - "has-flag": "^3.0.0" + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, - "wrappy": { + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", diff --git a/package.json b/package.json index ffd5461..30ceecb 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,7 @@ "name": "gitlab-search", "version": "1.5.0", "scripts": { - "build": "rimraf dist && bsb -make-world && ncc build lib/js/src/Main.bs.js --out dist", - "start": "bsb -make-world -w", - "clean": "bsb -clean-world", + "build": "rimraf dist && npx tsc && ncc build dist/src/main.js --out dist", "prepublish": "npm run build" }, "keywords": [ @@ -19,13 +17,16 @@ ], "bin": "bin/gitlab-search.js", "devDependencies": { - "@glennsl/bs-json": "^5.0.2", - "@zeit/ncc": "^0.22.0", - "bs-axios": "0.0.43", - "bs-chalk": "^0.2.1", - "bs-platform": "^5.2.1", + "@types/node": "^22.10.2", + "@types/rc": "^1.2.4", + "@vercel/ncc": "^0.38.3", "commander": "^5.0.0", "rc": "^1.2.8", - "rimraf": "^3.0.2" + "rimraf": "^3.0.2", + "typescript": "^5.7.2" + }, + "dependencies": { + "axios": "^1.7.9", + "chalk": "^5.4.1" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2f32fa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "typeRoots": ["node_modules/@types"], + "types": ["node"], + "resolveJsonModule": true, + "outDir": "./dist", + }, + "exclude": ["node_modules"], +} From 4e6c5bb78581bfc0ffe8cf644f59a2451a7ee0ea Mon Sep 17 00:00:00 2001 From: Phillip Johnsen Date: Sat, 28 Dec 2024 22:33:52 +0100 Subject: [PATCH 3/5] 2.0.0-beta1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9081dad..b942d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-search", - "version": "1.5.0", + "version": "2.0.0-beta1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-search", - "version": "1.5.0", + "version": "2.0.0-beta1", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 30ceecb..23199c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitlab-search", - "version": "1.5.0", + "version": "2.0.0-beta1", "scripts": { "build": "rimraf dist && npx tsc && ncc build dist/src/main.js --out dist", "prepublish": "npm run build" From 0399e803664c2c04a13af4ba476b4c75b9b86b20 Mon Sep 17 00:00:00 2001 From: Phillip Johnsen Date: Sat, 28 Dec 2024 22:41:51 +0100 Subject: [PATCH 4/5] Bump CI from Node.js v14 -> v21 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 94b313a..c83d6e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v1 with: - node-version: 14.x + node-version: 21.x - run: node --version - run: npm --version - name: npm install and build From 9de63d7a6f924ec53dfafac55a668557d2691c76 Mon Sep 17 00:00:00 2001 From: Phillip Johnsen Date: Sat, 28 Dec 2024 22:44:54 +0100 Subject: [PATCH 5/5] `package.json` fields upgrades As discovered when publishing a new beta version: ``` npm WARN publish npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors. npm WARN publish errors corrected: npm WARN publish "bin" was converted to an object npm WARN publish "repository" was changed from a string to an object npm WARN publish "repository.url" was normalized to "git+https://github.com/phillipj/gitlab-search.git" ``` Changes were made by `npm pkg fix`. --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 23199c2..4f4af66 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,17 @@ ], "author": "Phillip Johnsen ", "license": "MIT", - "repository": "github:phillipj/gitlab-search", + "repository": { + "type": "git", + "url": "git+https://github.com/phillipj/gitlab-search.git" + }, "files": [ "bin", "dist" ], - "bin": "bin/gitlab-search.js", + "bin": { + "gitlab-search": "bin/gitlab-search.js" + }, "devDependencies": { "@types/node": "^22.10.2", "@types/rc": "^1.2.4",