From a93dc2679f23008293b9b92ca3a5c7c7b796a8b7 Mon Sep 17 00:00:00 2001 From: Alex David Date: Wed, 29 Nov 2023 09:28:57 -0800 Subject: [PATCH 1/7] Implement importFlake --- ts/importFlake.test.ts | 226 +++++++++++++++++++++++++++++++++++++++++ ts/importFlake.ts | 63 ++++++++++++ ts/internal/utils.ts | 21 ++-- ts/mod.ts | 1 + ts/package.test.ts | 16 +++ ts/package.ts | 13 ++- ts/testUtils.ts | 32 ++++++ 7 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 ts/importFlake.test.ts create mode 100644 ts/importFlake.ts create mode 100644 ts/package.test.ts diff --git a/ts/importFlake.test.ts b/ts/importFlake.test.ts new file mode 100644 index 00000000..e6bed9c8 --- /dev/null +++ b/ts/importFlake.test.ts @@ -0,0 +1,226 @@ +import { + afterEach, + beforeEach, + describe, + it, +} from "https://deno.land/std@0.206.0/testing/bdd.ts"; +import { + assertStderrContains, + assertStdout, + assertSuccess, + enterEnvironment, + runCheck, + runCommand, + runExecutable, +} from "./testUtils.ts"; +import { importFlake } from "./importFlake.ts"; +import * as garn from "./mod.ts"; +import { existsSync } from "https://deno.land/std@0.201.0/fs/mod.ts"; + +describe("importFlake", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = Deno.makeTempDirSync({ prefix: "garn-test" }); + }); + + afterEach(() => { + Deno.removeSync(tempDir, { recursive: true }); + }); + + const writeFlakeToImport = (contents: string) => { + try { + Deno.mkdirSync(`${tempDir}/to-import`); + } catch (err) { + if (!(err instanceof Deno.errors.AlreadyExists)) throw err; + } + Deno.writeTextFileSync( + `${tempDir}/to-import/flake.nix`, + ` + { + inputs.nixpkgs.url = "github:NixOS/nixpkgs/6fc7203e423bbf1c8f84cccf1c4818d097612566"; + inputs.flake-utils.url = "github:numtide/flake-utils/ff7b65b44d01cf9ba6a71320833626af21126384"; + outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: + let pkgs = import "\${nixpkgs}" { inherit system; }; in ${contents} + ); + } + `, + ); + if (!existsSync(`${tempDir}/to-import/flake.lock`)) { + assertSuccess( + runCommand( + new Deno.Command("nix", { + args: ["flake", "lock"], + cwd: `${tempDir}/to-import`, + }), + ), + ); + } + }; + + describe("getPackage", () => { + it("returns the specified package from the flake file", () => { + writeFlakeToImport(`{ + packages = { + main_pkg = pkgs.runCommand "create-some-files" {} '' + mkdir $out + touch $out/foo $out/bar + ''; + }; + }`); + const flake = importFlake("./to-import"); + const exe = garn.shell`ls ${flake.getPackage("main_pkg")}`; + const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); + assertStdout(output, "bar\nfoo\n"); + }); + + it("reflects changes when changing the flake source after importing", () => { + writeFlakeToImport(`{ + packages = { + main_pkg = pkgs.runCommand "create-some-files" {} '' + mkdir $out + touch $out/foo $out/bar + ''; + }; + }`); + const flake = importFlake(`./to-import`); + const exe = garn.shell`ls ${flake.getPackage("main_pkg")}`; + assertSuccess(runExecutable(exe, { cwd: tempDir })); + writeFlakeToImport(`{ + packages = { + main_pkg = pkgs.runCommand "create-some-files" {} '' + mkdir $out + touch $out/baz + ''; + }; + }`); + const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); + assertStdout(output, "baz\n"); + }); + + it("allows importing relative sources in packages", () => { + Deno.writeTextFileSync( + `${tempDir}/foo.js`, + "console.log('Hello from a js file in tempDir!')", + ); + writeFlakeToImport(`{ + packages = { + foo = pkgs.writeScript "foo" '' + \${pkgs.nodejs}/bin/node \${../foo.js} + ''; + }; + }`); + const flake = importFlake("./to-import"); + const exe = garn.shell`${flake.getPackage("foo")}`; + const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); + assertStdout(output, "Hello from a js file in tempDir!\n"); + }); + }); + + describe("getApp", () => { + it("returns the specified executable from the flake file apps", () => { + writeFlakeToImport(`{ + apps = { + hello = { + type = "app"; + program = builtins.toString (pkgs.writeScript "hello" '' + echo hello from flake file + ''); + }; + }; + }`); + const flake = importFlake("./to-import"); + const exe = flake.getApp("hello"); + const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); + assertStdout(output, "hello from flake file\n"); + }); + }); + + describe("getCheck", () => { + it("returns the specified check from the flake file", () => { + writeFlakeToImport(`{ + checks = { + some-check = pkgs.runCommand "my-check" {} '' + # ${Date.now()} + touch $out + echo running my-check! + ''; + }; + }`); + const flake = importFlake("./to-import"); + const check = flake.getCheck("some-check"); + const output = assertSuccess(runCheck(check, { dir: tempDir })); + assertStderrContains(output, "running my-check!"); + }); + }); + + describe("getDevShell", () => { + it("returns the specified environment from the flake file", () => { + writeFlakeToImport(`{ + devShells = { + some-shell = pkgs.mkShell { + nativeBuildInputs = with pkgs; [ hello ]; + }; + }; + }`); + const flake = importFlake("./to-import"); + const env = flake.getDevShell("some-shell"); + const output = enterEnvironment(env, ["hello"], { dir: tempDir }); + assertStdout(output, "Hello, world!\n"); + }); + }); + + describe("allPackages", () => { + it("returns a package with symlinks to all found packages", () => { + writeFlakeToImport(`{ + packages = { + foo = pkgs.runCommand "create-foo" {} "echo foo-pkg > $out"; + bar = pkgs.runCommand "create-bar" {} "echo bar-pkg > $out"; + }; + }`); + const flake = importFlake("./to-import"); + const pkg = flake.allPackages; + const assertCmd = (exe: garn.Executable, expectedStdout: string) => + assertStdout( + assertSuccess(runExecutable(exe, { cwd: tempDir })), + expectedStdout, + ); + assertCmd(garn.shell`ls ${pkg}`, "bar\nfoo\n"); + assertCmd(garn.shell`cat ${pkg}/foo`, "foo-pkg\n"); + assertCmd(garn.shell`cat ${pkg}/bar`, "bar-pkg\n"); + }); + }); + + describe("getAllChecks", () => { + it("returns a check that composes all found checks", () => { + writeFlakeToImport(`{ + checks = { + foo = pkgs.runCommand "check-foo" {} '' + # ${Date.now()} + touch $out + echo running check foo + ''; + bar = pkgs.runCommand "check-bar" {} '' + # ${Date.now()} + touch $out + echo running check bar + ''; + }; + }`); + const flake = importFlake("./to-import"); + const output = assertSuccess(runCheck(flake.allChecks, { dir: tempDir })); + assertStderrContains(output, "running check foo"); + assertStderrContains(output, "running check bar"); + }); + }); + + it("allows importing flakes from url", () => { + const flake = importFlake( + "github:garnix-io/debug-tools/8a4026fa6ccbfec070f96d458ffa96e7fb6112e8", + ); + const exe = flake.getPackage("main_pkg").bin("debug-args"); + const output = assertSuccess(runExecutable(exe)); + assertStdout(output, ""); + assertStderrContains(output, "[]"); + }); +}); diff --git a/ts/importFlake.ts b/ts/importFlake.ts new file mode 100644 index 00000000..df9b10da --- /dev/null +++ b/ts/importFlake.ts @@ -0,0 +1,63 @@ +import { Check } from "./check.ts"; +import { Environment, mkEnvironment } from "./environment.ts"; +import { Executable, mkExecutable } from "./executable.ts"; +import { NixExpression, nixFlakeDep, nixRaw, nixStrLit } from "./nix.ts"; +import { mkPackage, Package } from "./package.ts"; + +function getFlake(flakeDir: string): NixExpression { + if (flakeDir.match(/^[.][.]?[/]/)) { + const callFlake = nixFlakeDep("call-flake", { + url: "github:divnix/call-flake", + }); + return nixRaw`(${callFlake} ${nixRaw(flakeDir)})`; + } + return nixFlakeDep(flakeDir.replaceAll(/[^a-zA-Z0-9]/g, "-"), { + url: flakeDir, + }); +} + +export function importFlake(flakeDir: string): { + allChecks: Check; + allPackages: Package; + getApp: (appName: string) => Executable; + getCheck: (appName: string) => Check; + getDevShell: (devShellName: string) => Environment; + getPackage: (packageName: string) => Package; +} { + const flake = getFlake(flakeDir); + return { + allChecks: { + tag: "check", + nixExpression: nixRaw`pkgs.linkFarm "all-checks" ${flake}.checks.\${system}`, + }, + + allPackages: mkPackage( + nixRaw`pkgs.linkFarm "all-packages" ${flake}.packages.\${system}`, + `All packages from flake.nix in ${flakeDir}`, + ), + + getApp: (appName) => + mkExecutable( + nixRaw`${flake}.apps.\${system}.${nixStrLit(appName)}.program`, + `Execute ${appName} from flake.nix in ${flakeDir}`, + ), + + getCheck: (checkName) => ({ + tag: "check", + nixExpression: nixRaw`${flake}.checks.\${system}.${nixStrLit(checkName)}`, + }), + + getDevShell: (devShellName) => + mkEnvironment({ + nixExpression: nixRaw`${flake}.devShells.\${system}.${nixStrLit( + devShellName, + )}`, + }), + + getPackage: (packageName) => + mkPackage( + nixRaw`${flake}.packages.\${system}.${nixStrLit(packageName)}`, + `${packageName} from flake.nix in ${flakeDir}`, + ), + }; +} diff --git a/ts/internal/utils.ts b/ts/internal/utils.ts index 655af3f5..d9c55458 100644 --- a/ts/internal/utils.ts +++ b/ts/internal/utils.ts @@ -41,20 +41,17 @@ export const mapKeys = ( return result; }; -/** - * Maps an object's values. Typescript currently does not keep track of the - * object structure, but this could easily be accomplished in the future in a - * backwards-compatible way. - */ -export const mapValues = ( - f: (i: T, key: string) => R, - x: Record, -): Record => { - const result: Record = {}; - for (const [key, value] of Object.entries(x)) { +export const mapValues = , U>( + f: (i: T[keyof T], key: keyof T) => U, + x: T, +): { [key in keyof T]: U } => { + const result: Partial<{ [key in keyof T]: U }> = {}; + for (const [key, value] of Object.entries(x) as Array< + [keyof T, T[keyof T]] + >) { result[key] = f(value, key); } - return result; + return result as { [key in keyof T]: U }; }; export const filterNullValues = ( diff --git a/ts/mod.ts b/ts/mod.ts index c2195fc0..50c04c82 100644 --- a/ts/mod.ts +++ b/ts/mod.ts @@ -25,3 +25,4 @@ export * as javascript from "./javascript/mod.ts"; export { processCompose } from "./process_compose.ts"; export { deployToGhPages } from "./deployToGhPages.ts"; export * as nix from "./nix.ts"; +export { importFlake } from "./importFlake.ts"; diff --git a/ts/package.test.ts b/ts/package.test.ts new file mode 100644 index 00000000..645ae2e9 --- /dev/null +++ b/ts/package.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from "https://deno.land/std@0.206.0/testing/bdd.ts"; +import { mkPackage } from "./package.ts"; +import { nixRaw } from "./nix.ts"; +import { assertStdout, assertSuccess, runExecutable } from "./testUtils.ts"; + +describe("bin", () => { + it("escapes executable names", () => { + const pkg = mkPackage( + nixRaw`pkgs.writeScriptBin "bin with spaces" "echo running bin with spaces"`, + "pkg", + ); + const exe = pkg.bin("bin with spaces"); + const output = assertSuccess(runExecutable(exe)); + assertStdout(output, "running bin with spaces\n"); + }); +}); diff --git a/ts/package.ts b/ts/package.ts index dac43f67..2b481072 100644 --- a/ts/package.ts +++ b/ts/package.ts @@ -1,8 +1,10 @@ -import { Environment, sandboxScript } from "./environment.ts"; +import { Environment, sandboxScript, shell } from "./environment.ts"; +import { Executable } from "./executable.ts"; import { hasTag } from "./internal/utils.ts"; import { NixExpression, NixStrLitInterpolatable, + escapeShellArg, nixRaw, nixStrLit, toHumanReadable, @@ -26,6 +28,11 @@ export type Package = { * Update the description for this `Package` */ setDescription: (this: Package, newDescription: string) => Package; + + /** + * Get an executable by name within the `bin` directory of this `Package` + */ + bin: (executableName: string) => Executable; }; export function isPackage(x: unknown): x is Package { @@ -50,6 +57,10 @@ export function mkPackage( description: newDescription, }; }, + + bin(this: Package, executableName: string): Executable { + return shell`${this}/bin/${escapeShellArg(nixStrLit(executableName))}`; + }, }; } diff --git a/ts/testUtils.ts b/ts/testUtils.ts index b8b0ce2e..3d2cf138 100644 --- a/ts/testUtils.ts +++ b/ts/testUtils.ts @@ -179,6 +179,38 @@ export const buildPackage = ( return Deno.readLinkSync(`${dir}/result`); }; +export const enterEnvironment = ( + env: garn.Environment, + command: Array, + options: { dir?: string } = {}, +) => { + const dir = options.dir ?? Deno.makeTempDirSync({ prefix: "garn-test" }); + const flakeFile = nix.renderFlakeFile( + nixAttrSet({ + devShells: nixAttrSet({ + "x86_64-linux": nixAttrSet({ + default: nix.nixRaw` + let + nixpkgs = ${nixpkgsInput}; + pkgs = ${pkgs}; + inherit (pkgs) system; + in ${env.nixExpression} + `, + }), + }), + }), + ); + Deno.writeTextFileSync(`${dir}/flake.nix`, flakeFile); + return assertSuccess( + runCommand( + new Deno.Command("nix", { + args: ["develop", "--command", "--", ...command], + cwd: dir, + }), + ), + ); +}; + export const testPkgs = { hello: mkPackage(nix.nixRaw("pkgs.hello"), "hello"), }; From 61a979fbeea2b4c4c54ad03e2b57576a607d346d Mon Sep 17 00:00:00 2001 From: Alex David Date: Wed, 29 Nov 2023 18:44:24 -0800 Subject: [PATCH 2/7] Address PR comments --- CHANGELOG.md | 2 + ts/importFlake.test.ts | 62 +++++++++++++++++++++++++------ ts/importFlake.ts | 55 ++++++++++++++++++--------- ts/internal/interpolatedString.ts | 7 ++++ ts/internal/utils.ts | 14 +++---- ts/nix.ts | 24 ++++++++++++ ts/package.ts | 5 ++- 7 files changed, 133 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4986d1c9..a1c4255b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add `garn.javascript.prettier` plugin to automatically format and check javascript formatting using [prettier](https://prettier.io/). +- Add `garn.importFlake` helper to allow extending a flake file by local path + or URL. ## v0.0.18 diff --git a/ts/importFlake.test.ts b/ts/importFlake.test.ts index e6bed9c8..b84e58f8 100644 --- a/ts/importFlake.test.ts +++ b/ts/importFlake.test.ts @@ -29,11 +29,7 @@ describe("importFlake", () => { }); const writeFlakeToImport = (contents: string) => { - try { - Deno.mkdirSync(`${tempDir}/to-import`); - } catch (err) { - if (!(err instanceof Deno.errors.AlreadyExists)) throw err; - } + Deno.mkdirSync(`${tempDir}/to-import`, { recursive: true }); Deno.writeTextFileSync( `${tempDir}/to-import/flake.nix`, ` @@ -79,7 +75,7 @@ describe("importFlake", () => { packages = { main_pkg = pkgs.runCommand "create-some-files" {} '' mkdir $out - touch $out/foo $out/bar + touch $out/original ''; }; }`); @@ -90,12 +86,12 @@ describe("importFlake", () => { packages = { main_pkg = pkgs.runCommand "create-some-files" {} '' mkdir $out - touch $out/baz + touch $out/modified ''; }; }`); const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); - assertStdout(output, "baz\n"); + assertStdout(output, "modified\n"); }); it("allows importing relative sources in packages", () => { @@ -115,6 +111,17 @@ describe("importFlake", () => { const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); assertStdout(output, "Hello from a js file in tempDir!\n"); }); + + it("displays a helpful error if the specified package does not exist", () => { + writeFlakeToImport(`{ packages = { }; }`); + const flake = importFlake("./to-import"); + const exe = garn.shell`${flake.getPackage("foo")}`; + const output = runExecutable(exe, { cwd: tempDir }); + assertStderrContains( + output, + 'error: The package "foo" was not found in ./to-import', + ); + }); }); describe("getApp", () => { @@ -134,6 +141,17 @@ describe("importFlake", () => { const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); assertStdout(output, "hello from flake file\n"); }); + + it("displays a helpful error if the specified app does not exist", () => { + writeFlakeToImport(`{ apps = { }; }`); + const flake = importFlake("./to-import"); + const exe = garn.shell`${flake.getApp("foo")}`; + const output = runExecutable(exe, { cwd: tempDir }); + assertStderrContains( + output, + 'error: The app "foo" was not found in ./to-import', + ); + }); }); describe("getCheck", () => { @@ -152,6 +170,17 @@ describe("importFlake", () => { const output = assertSuccess(runCheck(check, { dir: tempDir })); assertStderrContains(output, "running my-check!"); }); + + it("displays a helpful error if the specified check does not exist", () => { + writeFlakeToImport(`{ checks = { }; }`); + const flake = importFlake("./to-import"); + const exe = garn.shell`${flake.getCheck("foo")}`; + const output = runExecutable(exe, { cwd: tempDir }); + assertStderrContains( + output, + 'error: The check "foo" was not found in ./to-import', + ); + }); }); describe("getDevShell", () => { @@ -159,7 +188,7 @@ describe("importFlake", () => { writeFlakeToImport(`{ devShells = { some-shell = pkgs.mkShell { - nativeBuildInputs = with pkgs; [ hello ]; + nativeBuildInputs = [ pkgs.hello ]; }; }; }`); @@ -168,14 +197,25 @@ describe("importFlake", () => { const output = enterEnvironment(env, ["hello"], { dir: tempDir }); assertStdout(output, "Hello, world!\n"); }); + + it("displays a helpful error if the specified devShell does not exist", () => { + writeFlakeToImport(`{ devShells = { }; }`); + const flake = importFlake("./to-import"); + const exe = garn.shell`${flake.getDevShell("foo")}`; + const output = runExecutable(exe, { cwd: tempDir }); + assertStderrContains( + output, + 'error: The devShell "foo" was not found in ./to-import', + ); + }); }); describe("allPackages", () => { it("returns a package with symlinks to all found packages", () => { writeFlakeToImport(`{ packages = { - foo = pkgs.runCommand "create-foo" {} "echo foo-pkg > $out"; - bar = pkgs.runCommand "create-bar" {} "echo bar-pkg > $out"; + foo = pkgs.runCommand "foo" {} "echo foo-pkg > $out"; + bar = pkgs.runCommand "bar" {} "echo bar-pkg > $out"; }; }`); const flake = importFlake("./to-import"); diff --git a/ts/importFlake.ts b/ts/importFlake.ts index df9b10da..86637186 100644 --- a/ts/importFlake.ts +++ b/ts/importFlake.ts @@ -1,22 +1,28 @@ import { Check } from "./check.ts"; import { Environment, mkEnvironment } from "./environment.ts"; import { Executable, mkExecutable } from "./executable.ts"; -import { NixExpression, nixFlakeDep, nixRaw, nixStrLit } from "./nix.ts"; +import { + getPathOrError, + NixExpression, + nixFlakeDep, + nixRaw, + nixStrLit, +} from "./nix.ts"; import { mkPackage, Package } from "./package.ts"; -function getFlake(flakeDir: string): NixExpression { - if (flakeDir.match(/^[.][.]?[/]/)) { +function getFlake(flakeUrl: string): NixExpression { + if (flakeUrl.match(/^[.][.]?[/]/)) { const callFlake = nixFlakeDep("call-flake", { url: "github:divnix/call-flake", }); - return nixRaw`(${callFlake} ${nixRaw(flakeDir)})`; + return nixRaw`(${callFlake} ${nixRaw(flakeUrl)})`; } - return nixFlakeDep(flakeDir.replaceAll(/[^a-zA-Z0-9]/g, "-"), { - url: flakeDir, + return nixFlakeDep(flakeUrl.replaceAll(/[^a-zA-Z0-9]/g, "-"), { + url: flakeUrl, }); } -export function importFlake(flakeDir: string): { +export function importFlake(flakeUrl: string): { allChecks: Check; allPackages: Package; getApp: (appName: string) => Executable; @@ -24,7 +30,8 @@ export function importFlake(flakeDir: string): { getDevShell: (devShellName: string) => Environment; getPackage: (packageName: string) => Package; } { - const flake = getFlake(flakeDir); + const flake = getFlake(flakeUrl); + return { allChecks: { tag: "check", @@ -33,31 +40,45 @@ export function importFlake(flakeDir: string): { allPackages: mkPackage( nixRaw`pkgs.linkFarm "all-packages" ${flake}.packages.\${system}`, - `All packages from flake.nix in ${flakeDir}`, + `All packages from flake.nix in ${flakeUrl}`, ), getApp: (appName) => mkExecutable( - nixRaw`${flake}.apps.\${system}.${nixStrLit(appName)}.program`, - `Execute ${appName} from flake.nix in ${flakeDir}`, + getPathOrError( + flake, + ["apps", nixRaw`\${system}`, appName, "program"], + nixStrLit`The app "${appName}" was not found in ${flakeUrl}`, + ), + `Execute ${appName} from flake.nix in ${flakeUrl}`, ), getCheck: (checkName) => ({ tag: "check", - nixExpression: nixRaw`${flake}.checks.\${system}.${nixStrLit(checkName)}`, + nixExpression: getPathOrError( + flake, + ["checks", nixRaw`\${system}`, checkName], + nixStrLit`The check "${checkName}" was not found in ${flakeUrl}`, + ), }), getDevShell: (devShellName) => mkEnvironment({ - nixExpression: nixRaw`${flake}.devShells.\${system}.${nixStrLit( - devShellName, - )}`, + nixExpression: getPathOrError( + flake, + ["devShells", nixRaw`\${system}`, devShellName], + nixStrLit`The devShell "${devShellName}" was not found in ${flakeUrl}`, + ), }), getPackage: (packageName) => mkPackage( - nixRaw`${flake}.packages.\${system}.${nixStrLit(packageName)}`, - `${packageName} from flake.nix in ${flakeDir}`, + getPathOrError( + flake, + ["packages", nixRaw`\${system}`, packageName], + nixStrLit`The package "${packageName}" was not found in ${flakeUrl}`, + ), + `${packageName} from flake.nix in ${flakeUrl}`, ), }; } diff --git a/ts/internal/interpolatedString.ts b/ts/internal/interpolatedString.ts index cd00f402..a82948e6 100644 --- a/ts/internal/interpolatedString.ts +++ b/ts/internal/interpolatedString.ts @@ -77,3 +77,10 @@ export function getInterpolations( ): Array { return interpolated.rest.map(([node, _str]) => node); } + +export function join(arr: Array, joiner: string): InterpolatedString { + return { + initial: "", + rest: arr.map((el, idx) => [el, idx < arr.length - 1 ? joiner : ""]), + }; +} diff --git a/ts/internal/utils.ts b/ts/internal/utils.ts index d9c55458..1b40f5d0 100644 --- a/ts/internal/utils.ts +++ b/ts/internal/utils.ts @@ -41,17 +41,17 @@ export const mapKeys = ( return result; }; -export const mapValues = , U>( - f: (i: T[keyof T], key: keyof T) => U, - x: T, -): { [key in keyof T]: U } => { - const result: Partial<{ [key in keyof T]: U }> = {}; +export const mapValues = , FnResult>( + f: (i: Obj[keyof Obj], key: keyof Obj) => FnResult, + x: Obj, +): { [key in keyof Obj]: FnResult } => { + const result: Partial<{ [key in keyof Obj]: FnResult }> = {}; for (const [key, value] of Object.entries(x) as Array< - [keyof T, T[keyof T]] + [keyof Obj, Obj[keyof Obj]] >) { result[key] = f(value, key); } - return result as { [key in keyof T]: U }; + return result as { [key in keyof Obj]: FnResult }; }; export const filterNullValues = ( diff --git a/ts/nix.ts b/ts/nix.ts index 924c33b7..2ed5a96e 100644 --- a/ts/nix.ts +++ b/ts/nix.ts @@ -3,6 +3,7 @@ import { InterpolatedString, interpolatedStringFromString, interpolatedStringFromTemplate, + join, mapStrings, renderInterpolatedString, } from "./internal/interpolatedString.ts"; @@ -210,6 +211,29 @@ export function escapeShellArg(shellArg: NixExpression): NixExpression { return nixRaw`(pkgs.lib.strings.escapeShellArg ${shellArg})`; } +export function getPathOrError( + attrSet: NixExpression, + path: Array, + error: NixExpression, +): NixExpression { + const pathExpr: NixExpression = { + [__nixExpressionTag]: null, + type: "raw", + raw: join( + path.map((el) => (typeof el === "string" ? nixStrLit(el) : el)), + ".", + ), + }; + return nixRaw` + let + x = ${attrSet}; + in + if x ? ${pathExpr} + then x.${pathExpr} + else builtins.throw ${error} + `; +} + /** * Returns a `NixExpression` that renders as an identifier that refers to a * flake input. At the same time it registers the flake input as a dependency, diff --git a/ts/package.ts b/ts/package.ts index 2b481072..b6ad264a 100644 --- a/ts/package.ts +++ b/ts/package.ts @@ -30,7 +30,10 @@ export type Package = { setDescription: (this: Package, newDescription: string) => Package; /** - * Get an executable by name within the `bin` directory of this `Package` + * Get an executable by name within the `bin` directory of this `Package`. + * + * If that executable doesn't exist this will only fail when trying to *run* + * the `Executable`, not before. */ bin: (executableName: string) => Executable; }; From f06c4d110c03acf3486e8c670cda42e5fda78b54 Mon Sep 17 00:00:00 2001 From: Alex David Date: Thu, 30 Nov 2023 13:39:19 -0800 Subject: [PATCH 3/7] Use buildPackage for `getPackage` tests --- ts/importFlake.test.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/ts/importFlake.test.ts b/ts/importFlake.test.ts index b84e58f8..a90c69bc 100644 --- a/ts/importFlake.test.ts +++ b/ts/importFlake.test.ts @@ -8,6 +8,7 @@ import { assertStderrContains, assertStdout, assertSuccess, + buildPackage, enterEnvironment, runCheck, runCommand, @@ -16,6 +17,7 @@ import { import { importFlake } from "./importFlake.ts"; import * as garn from "./mod.ts"; import { existsSync } from "https://deno.land/std@0.201.0/fs/mod.ts"; +import { assertEquals } from "https://deno.land/std@0.206.0/assert/mod.ts"; describe("importFlake", () => { let tempDir: string; @@ -65,9 +67,8 @@ describe("importFlake", () => { }; }`); const flake = importFlake("./to-import"); - const exe = garn.shell`ls ${flake.getPackage("main_pkg")}`; - const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); - assertStdout(output, "bar\nfoo\n"); + const pkg = buildPackage(flake.getPackage("main_pkg"), { dir: tempDir }); + assertEquals(getDirEntryNames(pkg), ["bar", "foo"]); }); it("reflects changes when changing the flake source after importing", () => { @@ -80,8 +81,7 @@ describe("importFlake", () => { }; }`); const flake = importFlake(`./to-import`); - const exe = garn.shell`ls ${flake.getPackage("main_pkg")}`; - assertSuccess(runExecutable(exe, { cwd: tempDir })); + buildPackage(flake.getPackage("main_pkg"), { dir: tempDir }); writeFlakeToImport(`{ packages = { main_pkg = pkgs.runCommand "create-some-files" {} '' @@ -90,8 +90,8 @@ describe("importFlake", () => { ''; }; }`); - const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); - assertStdout(output, "modified\n"); + const pkg = buildPackage(flake.getPackage("main_pkg"), { dir: tempDir }); + assertEquals(getDirEntryNames(pkg), ["modified"]); }); it("allows importing relative sources in packages", () => { @@ -219,15 +219,10 @@ describe("importFlake", () => { }; }`); const flake = importFlake("./to-import"); - const pkg = flake.allPackages; - const assertCmd = (exe: garn.Executable, expectedStdout: string) => - assertStdout( - assertSuccess(runExecutable(exe, { cwd: tempDir })), - expectedStdout, - ); - assertCmd(garn.shell`ls ${pkg}`, "bar\nfoo\n"); - assertCmd(garn.shell`cat ${pkg}/foo`, "foo-pkg\n"); - assertCmd(garn.shell`cat ${pkg}/bar`, "bar-pkg\n"); + const pkg = buildPackage(flake.allPackages, { dir: tempDir }); + assertEquals(getDirEntryNames(pkg), ["bar", "foo"]); + assertEquals(Deno.readTextFileSync(`${pkg}/foo`), "foo-pkg\n"); + assertEquals(Deno.readTextFileSync(`${pkg}/bar`), "bar-pkg\n"); }); }); @@ -264,3 +259,7 @@ describe("importFlake", () => { assertStderrContains(output, "[]"); }); }); + +function getDirEntryNames(path: string) { + return [...Deno.readDirSync(path)].map((entry) => entry.name); +} From 3c05d0c4e49a54377e5cdc045fa2cdb5999c7fe9 Mon Sep 17 00:00:00 2001 From: Alex David Date: Thu, 30 Nov 2023 13:43:05 -0800 Subject: [PATCH 4/7] Pull in runInDevShell from #466 --- ts/importFlake.test.ts | 4 ++-- ts/testUtils.ts | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/ts/importFlake.test.ts b/ts/importFlake.test.ts index a90c69bc..32dd8878 100644 --- a/ts/importFlake.test.ts +++ b/ts/importFlake.test.ts @@ -9,7 +9,7 @@ import { assertStdout, assertSuccess, buildPackage, - enterEnvironment, + runInDevShell, runCheck, runCommand, runExecutable, @@ -194,7 +194,7 @@ describe("importFlake", () => { }`); const flake = importFlake("./to-import"); const env = flake.getDevShell("some-shell"); - const output = enterEnvironment(env, ["hello"], { dir: tempDir }); + const output = runInDevShell(env, { cmd: "hello", dir: tempDir }); assertStdout(output, "Hello, world!\n"); }); diff --git a/ts/testUtils.ts b/ts/testUtils.ts index 3d2cf138..d855afcf 100644 --- a/ts/testUtils.ts +++ b/ts/testUtils.ts @@ -179,22 +179,21 @@ export const buildPackage = ( return Deno.readLinkSync(`${dir}/result`); }; -export const enterEnvironment = ( - env: garn.Environment, - command: Array, - options: { dir?: string } = {}, -) => { +export const runInDevShell = ( + devShell: garn.Environment, + options: { cmd: string; dir?: string } = { cmd: "true" }, +): Output => { const dir = options.dir ?? Deno.makeTempDirSync({ prefix: "garn-test" }); const flakeFile = nix.renderFlakeFile( nixAttrSet({ - devShells: nixAttrSet({ + packages: nixAttrSet({ "x86_64-linux": nixAttrSet({ default: nix.nixRaw` let nixpkgs = ${nixpkgsInput}; pkgs = ${pkgs}; inherit (pkgs) system; - in ${env.nixExpression} + in ${devShell.nixExpression} `, }), }), @@ -204,7 +203,7 @@ export const enterEnvironment = ( return assertSuccess( runCommand( new Deno.Command("nix", { - args: ["develop", "--command", "--", ...command], + args: ["develop", "-L", dir, "-c", "--", "bash", "-c", options.cmd], cwd: dir, }), ), From 1cb9af6b41eca4e2d9f8869da34306f9c77f3533 Mon Sep 17 00:00:00 2001 From: Alex David Date: Sat, 2 Dec 2023 13:29:04 -0800 Subject: [PATCH 5/7] Separate callFlake from importFlake and add jsdoc --- ts/importFlake.test.ts | 52 +++++++--- ts/importFlake.ts | 228 +++++++++++++++++++++++++++++++++++++---- ts/nix.ts | 2 +- ts/testUtils.ts | 12 +++ 4 files changed, 256 insertions(+), 38 deletions(-) diff --git a/ts/importFlake.test.ts b/ts/importFlake.test.ts index 32dd8878..3a3e84ab 100644 --- a/ts/importFlake.test.ts +++ b/ts/importFlake.test.ts @@ -8,18 +8,19 @@ import { assertStderrContains, assertStdout, assertSuccess, + assertThrowsWith, buildPackage, - runInDevShell, runCheck, runCommand, runExecutable, + runInDevShell, } from "./testUtils.ts"; -import { importFlake } from "./importFlake.ts"; +import { callFlake, importFlake, importFromGithub } from "./importFlake.ts"; import * as garn from "./mod.ts"; import { existsSync } from "https://deno.land/std@0.201.0/fs/mod.ts"; import { assertEquals } from "https://deno.land/std@0.206.0/assert/mod.ts"; -describe("importFlake", () => { +describe("callFlake", () => { let tempDir: string; beforeEach(() => { @@ -66,7 +67,7 @@ describe("importFlake", () => { ''; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const pkg = buildPackage(flake.getPackage("main_pkg"), { dir: tempDir }); assertEquals(getDirEntryNames(pkg), ["bar", "foo"]); }); @@ -80,7 +81,7 @@ describe("importFlake", () => { ''; }; }`); - const flake = importFlake(`./to-import`); + const flake = callFlake(`./to-import`); buildPackage(flake.getPackage("main_pkg"), { dir: tempDir }); writeFlakeToImport(`{ packages = { @@ -106,7 +107,7 @@ describe("importFlake", () => { ''; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const exe = garn.shell`${flake.getPackage("foo")}`; const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); assertStdout(output, "Hello from a js file in tempDir!\n"); @@ -114,7 +115,7 @@ describe("importFlake", () => { it("displays a helpful error if the specified package does not exist", () => { writeFlakeToImport(`{ packages = { }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const exe = garn.shell`${flake.getPackage("foo")}`; const output = runExecutable(exe, { cwd: tempDir }); assertStderrContains( @@ -136,7 +137,7 @@ describe("importFlake", () => { }; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const exe = flake.getApp("hello"); const output = assertSuccess(runExecutable(exe, { cwd: tempDir })); assertStdout(output, "hello from flake file\n"); @@ -144,7 +145,7 @@ describe("importFlake", () => { it("displays a helpful error if the specified app does not exist", () => { writeFlakeToImport(`{ apps = { }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const exe = garn.shell`${flake.getApp("foo")}`; const output = runExecutable(exe, { cwd: tempDir }); assertStderrContains( @@ -165,7 +166,7 @@ describe("importFlake", () => { ''; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const check = flake.getCheck("some-check"); const output = assertSuccess(runCheck(check, { dir: tempDir })); assertStderrContains(output, "running my-check!"); @@ -173,7 +174,7 @@ describe("importFlake", () => { it("displays a helpful error if the specified check does not exist", () => { writeFlakeToImport(`{ checks = { }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const exe = garn.shell`${flake.getCheck("foo")}`; const output = runExecutable(exe, { cwd: tempDir }); assertStderrContains( @@ -192,7 +193,7 @@ describe("importFlake", () => { }; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const env = flake.getDevShell("some-shell"); const output = runInDevShell(env, { cmd: "hello", dir: tempDir }); assertStdout(output, "Hello, world!\n"); @@ -200,7 +201,7 @@ describe("importFlake", () => { it("displays a helpful error if the specified devShell does not exist", () => { writeFlakeToImport(`{ devShells = { }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const exe = garn.shell`${flake.getDevShell("foo")}`; const output = runExecutable(exe, { cwd: tempDir }); assertStderrContains( @@ -218,7 +219,7 @@ describe("importFlake", () => { bar = pkgs.runCommand "bar" {} "echo bar-pkg > $out"; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const pkg = buildPackage(flake.allPackages, { dir: tempDir }); assertEquals(getDirEntryNames(pkg), ["bar", "foo"]); assertEquals(Deno.readTextFileSync(`${pkg}/foo`), "foo-pkg\n"); @@ -242,13 +243,15 @@ describe("importFlake", () => { ''; }; }`); - const flake = importFlake("./to-import"); + const flake = callFlake("./to-import"); const output = assertSuccess(runCheck(flake.allChecks, { dir: tempDir })); assertStderrContains(output, "running check foo"); assertStderrContains(output, "running check bar"); }); }); +}); +describe("importFlake", () => { it("allows importing flakes from url", () => { const flake = importFlake( "github:garnix-io/debug-tools/8a4026fa6ccbfec070f96d458ffa96e7fb6112e8", @@ -260,6 +263,25 @@ describe("importFlake", () => { }); }); +describe("importFromGithub", () => { + it("allows importing flakes from GitHub repositories", () => { + const flake = importFromGithub("garnix-io/debug-tools", { + revOrRef: "8a4026fa6ccbfec070f96d458ffa96e7fb6112e8", + }); + const exe = flake.getPackage("main_pkg").bin("debug-args"); + const output = assertSuccess(runExecutable(exe)); + assertStdout(output, ""); + assertStderrContains(output, "[]"); + }); + + it("throws an error if the repo is malformed", () => { + assertThrowsWith( + "The `repo` of a hosted git service should match /", + () => importFromGithub("/foo/bar"), + ); + }); +}); + function getDirEntryNames(path: string) { return [...Deno.readDirSync(path)].map((entry) => entry.name); } diff --git a/ts/importFlake.ts b/ts/importFlake.ts index 86637186..f9cf674f 100644 --- a/ts/importFlake.ts +++ b/ts/importFlake.ts @@ -1,6 +1,7 @@ import { Check } from "./check.ts"; import { Environment, mkEnvironment } from "./environment.ts"; import { Executable, mkExecutable } from "./executable.ts"; +import { filterNullValues } from "./internal/utils.ts"; import { getPathOrError, NixExpression, @@ -10,28 +11,211 @@ import { } from "./nix.ts"; import { mkPackage, Package } from "./package.ts"; -function getFlake(flakeUrl: string): NixExpression { - if (flakeUrl.match(/^[.][.]?[/]/)) { - const callFlake = nixFlakeDep("call-flake", { - url: "github:divnix/call-flake", - }); - return nixRaw`(${callFlake} ${nixRaw(flakeUrl)})`; - } - return nixFlakeDep(flakeUrl.replaceAll(/[^a-zA-Z0-9]/g, "-"), { - url: flakeUrl, - }); -} +/** + * Options for importing flake files from GitHub/GitLab/SourceHut. + */ +export type HostedGitServiceOptions = { + /** + * The path to a subdirectory in the repository containing the flake file to + * import. + */ + dir?: string; + + /** + * The name of a branch/tag, or a commit hash to import. + */ + revOrRef?: string; -export function importFlake(flakeUrl: string): { + /** + * A host to fetch from other than the default for that hosted service + * (useful if pulling from a self-hosted GitHub/GitLab/Sourcehut instance). + */ + host?: string; +}; + +/** + * Methods for obtaining apps, checks, dev-shells, and packages from an + * imported flakefile to include in your garn project. + */ +export type ImportedFlake = { + /** + * A check that composes all checks found in the imported flake file. + */ allChecks: Check; + + /** + * A package that composes all packages found in the imported flake file. + * + * When building this package the result will be a directory of symlinks to + * all packages in the flake file. + */ allPackages: Package; + + /** + * Obtain a specific `Executable` from the `apps` section of the imported + * flake file. + * + * Throws an error if the specified `appName` does not exist. + */ getApp: (appName: string) => Executable; - getCheck: (appName: string) => Check; + + /** + * Obtain a specific `Check` from the `checks` section of the imported flake + * file. + * + * Throws an error if the specified `checkName` does not exist. + */ + getCheck: (checkName: string) => Check; + + /** + * Obtain a specific `Environment` from the `devShells` section of the + * imported flake file. + * + * Throws an error if the specified `devShellName` does not exist. + */ getDevShell: (devShellName: string) => Environment; + + /** + * Obtain a specific `Package` from the `packages` section of the imported + * flake file. + * + * Throws an error if the specified `packageName` does not exist. + */ getPackage: (packageName: string) => Package; -} { - const flake = getFlake(flakeUrl); +}; + +/** + * Imports a flake file from a GitHub repository. + * + * See `importFlake` for details. + */ +export function importFromGithub( + repo: string, + options: HostedGitServiceOptions = {}, +): ImportedFlake { + return importFlakeFromHostedGitService("github", repo, options); +} + +/** + * Imports a flake file from a GitLab repository. + * + * See `importFlake` for details. + */ +export function importFromGitlab( + repo: string, + options: HostedGitServiceOptions = {}, +): ImportedFlake { + return importFlakeFromHostedGitService("gitlab", repo, options); +} + +/** + * Imports a flake file from a SourceHut repository. + * + * See `importFlake` for details. + */ +export function importFromSourcehut( + repo: string, + options: HostedGitServiceOptions = {}, +): ImportedFlake { + return importFlakeFromHostedGitService("sourcehut", repo, options); +} + +/** + * Gets apps, checks, dev-shells, and packages from an existing flake file by + * adding it as a flake input to your project. + * + * This is most useful for bringing in flake files from the internet. The hash + * of the flake file and its referenced sources will be included in your + * project's `flake.lock`. If you want to import a flake file within your + * current project, consider using `callFlake` instead which does not update + * the hash in your flake.lock. + * + * See https://nixos.org/manual/nix/unstable/command-ref/new-cli/nix3-flake.html + * for information on how to format `flakeUrl`. You may also want to use + * `importFromGithub`, `importFromGitlab` or `importFromSourcehut` helpers + * instead which provide a more ergonomic API when importing from these + * services. + * + * Example: + * ```typescript + * const myFlake = importFlake("git+https://example.com/some-repo.git?ref=main"); + * + * const myProject = getGarnProjectSomehow() + * .add(() => ({ + * format: myFlake.getApp("format"), + * env: myFlake.getDevShell("default"), + * bundle: myFlake.getPackage("bundle"), + * allChecks: myFlake.allChecks, + * })); + * ``` + */ +export function importFlake(flakeUrl: string): ImportedFlake { + return processFlake( + nixFlakeDep("importedFlake-" + flakeUrl.replaceAll(/[^a-zA-Z0-9]/g, "_"), { + url: flakeUrl, + }), + flakeUrl, + ); +} + +/** + * Gets apps, checks, dev-shells, and packages from an existing flake file + * within your current project. + * + * @param flakeDir The relative path to the directory containing a `flake.nix` + * + * Absolute `flakeDir`s and paths outside of the garn project are not + * supported. Consider using `importFlake` instead if you want to import an + * absolute flake path or one from a URL. + * + * This can be used to migrate an existing flake project to use garn. + * + * Example: + * ```typescript + * const myFlake = callFlake("./path/to/dir"); + * + * const myProject = getGarnProjectSomehow() + * .add(() => ({ + * format: myFlake.getApp("format"), + * env: myFlake.getDevShell("default"), + * bundle: myFlake.getPackage("bundle"), + * allChecks: myFlake.allChecks, + * })); + * ``` + */ +export function callFlake(flakeDir: string): ImportedFlake { + const callFlake = nixFlakeDep("call-flake", { + url: "github:divnix/call-flake", + }); + return processFlake(nixRaw`(${callFlake} ${nixRaw(flakeDir)})`, flakeDir); +} + +function importFlakeFromHostedGitService( + serviceName: string, + repo: string, + opts: HostedGitServiceOptions, +): ImportedFlake { + if (!repo.match(/^[^/]+\/[^/]+$/)) { + throw new Error( + "The `repo` of a hosted git service should match /", + ); + } + const query = new URLSearchParams( + filterNullValues({ + dir: opts.dir, + host: opts.host, + }), + ).toString(); + return importFlake( + serviceName + + ":" + + repo + + (opts.revOrRef ? `/${opts.revOrRef}` : "") + + (query ? `?${query}` : ""), + ); +} +function processFlake(flake: NixExpression, location: string): ImportedFlake { return { allChecks: { tag: "check", @@ -40,7 +224,7 @@ export function importFlake(flakeUrl: string): { allPackages: mkPackage( nixRaw`pkgs.linkFarm "all-packages" ${flake}.packages.\${system}`, - `All packages from flake.nix in ${flakeUrl}`, + `All packages from flake.nix in ${flake}`, ), getApp: (appName) => @@ -48,9 +232,9 @@ export function importFlake(flakeUrl: string): { getPathOrError( flake, ["apps", nixRaw`\${system}`, appName, "program"], - nixStrLit`The app "${appName}" was not found in ${flakeUrl}`, + nixStrLit`The app "${appName}" was not found in ${location}`, ), - `Execute ${appName} from flake.nix in ${flakeUrl}`, + `Execute ${appName} from flake.nix in ${flake}`, ), getCheck: (checkName) => ({ @@ -58,7 +242,7 @@ export function importFlake(flakeUrl: string): { nixExpression: getPathOrError( flake, ["checks", nixRaw`\${system}`, checkName], - nixStrLit`The check "${checkName}" was not found in ${flakeUrl}`, + nixStrLit`The check "${checkName}" was not found in ${location}`, ), }), @@ -67,7 +251,7 @@ export function importFlake(flakeUrl: string): { nixExpression: getPathOrError( flake, ["devShells", nixRaw`\${system}`, devShellName], - nixStrLit`The devShell "${devShellName}" was not found in ${flakeUrl}`, + nixStrLit`The devShell "${devShellName}" was not found in ${location}`, ), }), @@ -76,9 +260,9 @@ export function importFlake(flakeUrl: string): { getPathOrError( flake, ["packages", nixRaw`\${system}`, packageName], - nixStrLit`The package "${packageName}" was not found in ${flakeUrl}`, + nixStrLit`The package "${packageName}" was not found in ${location}`, ), - `${packageName} from flake.nix in ${flakeUrl}`, + `${packageName} from flake.nix in ${flake}`, ), }; } diff --git a/ts/nix.ts b/ts/nix.ts index 2ed5a96e..cc432081 100644 --- a/ts/nix.ts +++ b/ts/nix.ts @@ -241,7 +241,7 @@ export function getPathOrError( * `renderFlakeFile` for an example. */ export function nixFlakeDep(name: string, dep: FlakeDep): NixExpression { - if (!name.match(/^[a-zA-Z0-9-]+$/)) { + if (!name.match(/^[a-zA-Z][a-zA-Z0-9-_]+$/)) { throw Error(`flakeDep: "${name}" is not a valid nix variable name`); } return { diff --git a/ts/testUtils.ts b/ts/testUtils.ts index d855afcf..6f8a7ee9 100644 --- a/ts/testUtils.ts +++ b/ts/testUtils.ts @@ -213,3 +213,15 @@ export const runInDevShell = ( export const testPkgs = { hello: mkPackage(nix.nixRaw("pkgs.hello"), "hello"), }; + +export const assertThrowsWith = ( + expectedErrorMessage: string, + fn: () => void, +) => { + try { + fn(); + throw `Expected fn to throw with ${expectedErrorMessage}, but it did not throw`; + } catch (err) { + assertEquals(err.message, expectedErrorMessage); + } +}; From 1968c75c81ed88fbd1b5503b049a1acdbc863e05 Mon Sep 17 00:00:00 2001 From: Alex David Date: Sat, 2 Dec 2023 13:31:08 -0800 Subject: [PATCH 6/7] Update mod.ts export --- ts/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/mod.ts b/ts/mod.ts index 50c04c82..59260431 100644 --- a/ts/mod.ts +++ b/ts/mod.ts @@ -25,4 +25,4 @@ export * as javascript from "./javascript/mod.ts"; export { processCompose } from "./process_compose.ts"; export { deployToGhPages } from "./deployToGhPages.ts"; export * as nix from "./nix.ts"; -export { importFlake } from "./importFlake.ts"; +export * from "./importFlake.ts"; From 9bcd58878d395ca141d0e9966be5d228123fa919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hahn?= Date: Tue, 5 Dec 2023 13:40:24 -0800 Subject: [PATCH 7/7] Review comments --- CHANGELOG.md | 4 +-- ts/importFlake.test.ts | 9 +++--- ts/importFlake.ts | 67 +++++++++++++++++++++--------------------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c4255b..fb7c2da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ - Add `garn.javascript.prettier` plugin to automatically format and check javascript formatting using [prettier](https://prettier.io/). -- Add `garn.importFlake` helper to allow extending a flake file by local path - or URL. +- Add `garn.callFlake` helper to allow importing flake files by local path. +- Add `garn.importFlake` helper to allow importing flake files by url, e.g. from GitHub. ## v0.0.18 diff --git a/ts/importFlake.test.ts b/ts/importFlake.test.ts index 3a3e84ab..4e35d5b1 100644 --- a/ts/importFlake.test.ts +++ b/ts/importFlake.test.ts @@ -39,7 +39,7 @@ describe("callFlake", () => { { inputs.nixpkgs.url = "github:NixOS/nixpkgs/6fc7203e423bbf1c8f84cccf1c4818d097612566"; inputs.flake-utils.url = "github:numtide/flake-utils/ff7b65b44d01cf9ba6a71320833626af21126384"; - outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: + outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import "\${nixpkgs}" { inherit system; }; in ${contents} ); } @@ -265,7 +265,8 @@ describe("importFlake", () => { describe("importFromGithub", () => { it("allows importing flakes from GitHub repositories", () => { - const flake = importFromGithub("garnix-io/debug-tools", { + const flake = importFromGithub({ + repo: "garnix-io/debug-tools", revOrRef: "8a4026fa6ccbfec070f96d458ffa96e7fb6112e8", }); const exe = flake.getPackage("main_pkg").bin("debug-args"); @@ -277,11 +278,11 @@ describe("importFromGithub", () => { it("throws an error if the repo is malformed", () => { assertThrowsWith( "The `repo` of a hosted git service should match /", - () => importFromGithub("/foo/bar"), + () => importFromGithub({ repo: "/foo/bar" }), ); }); }); function getDirEntryNames(path: string) { - return [...Deno.readDirSync(path)].map((entry) => entry.name); + return [...Deno.readDirSync(path)].map((entry) => entry.name).sort(); } diff --git a/ts/importFlake.ts b/ts/importFlake.ts index f9cf674f..f92de48b 100644 --- a/ts/importFlake.ts +++ b/ts/importFlake.ts @@ -12,20 +12,26 @@ import { import { mkPackage, Package } from "./package.ts"; /** - * Options for importing flake files from GitHub/GitLab/SourceHut. + * Config for importing flake files from GitHub/GitLab/SourceHut. */ -export type HostedGitServiceOptions = { +export type RepoConfig = { /** - * The path to a subdirectory in the repository containing the flake file to - * import. + * The full repo name, i.e. the user handle and the repo name separated by a + * slash, e.g. `garnix-io/garn`. */ - dir?: string; + repo: string; /** * The name of a branch/tag, or a commit hash to import. */ revOrRef?: string; + /** + * The path to a subdirectory in the repository containing the flake file to + * import. + */ + dir?: string; + /** * A host to fetch from other than the default for that hosted service * (useful if pulling from a self-hosted GitHub/GitLab/Sourcehut instance). @@ -34,20 +40,23 @@ export type HostedGitServiceOptions = { }; /** - * Methods for obtaining apps, checks, dev-shells, and packages from an - * imported flakefile to include in your garn project. + * Methods for obtaining apps, checks, dev-shells, and packages from an imported + * flake file to include in your garn project. */ export type ImportedFlake = { /** - * A check that composes all checks found in the imported flake file. + * A check that combines all checks found in the imported flake file. + * + * I.e. `allChecks` will succeed only if *all* checks in the imported flake file + * succeed. */ allChecks: Check; /** - * A package that composes all packages found in the imported flake file. + * A package that combines all packages found in the imported flake file. * * When building this package the result will be a directory of symlinks to - * all packages in the flake file. + * all packages in the imported flake file. */ allPackages: Package; @@ -55,7 +64,7 @@ export type ImportedFlake = { * Obtain a specific `Executable` from the `apps` section of the imported * flake file. * - * Throws an error if the specified `appName` does not exist. + * `garn` will error if the specified `appName` does not exist. */ getApp: (appName: string) => Executable; @@ -63,7 +72,7 @@ export type ImportedFlake = { * Obtain a specific `Check` from the `checks` section of the imported flake * file. * - * Throws an error if the specified `checkName` does not exist. + * `garn` will error if the specified `checkName` does not exist. */ getCheck: (checkName: string) => Check; @@ -71,7 +80,7 @@ export type ImportedFlake = { * Obtain a specific `Environment` from the `devShells` section of the * imported flake file. * - * Throws an error if the specified `devShellName` does not exist. + * `garn` will error if the specified `devShellName` does not exist. */ getDevShell: (devShellName: string) => Environment; @@ -79,7 +88,7 @@ export type ImportedFlake = { * Obtain a specific `Package` from the `packages` section of the imported * flake file. * - * Throws an error if the specified `packageName` does not exist. + * `garn` will error if the specified `packageName` does not exist. */ getPackage: (packageName: string) => Package; }; @@ -89,11 +98,8 @@ export type ImportedFlake = { * * See `importFlake` for details. */ -export function importFromGithub( - repo: string, - options: HostedGitServiceOptions = {}, -): ImportedFlake { - return importFlakeFromHostedGitService("github", repo, options); +export function importFromGithub(options: RepoConfig): ImportedFlake { + return importFlakeFromRepoConfig("github", options); } /** @@ -101,11 +107,8 @@ export function importFromGithub( * * See `importFlake` for details. */ -export function importFromGitlab( - repo: string, - options: HostedGitServiceOptions = {}, -): ImportedFlake { - return importFlakeFromHostedGitService("gitlab", repo, options); +export function importFromGitlab(options: RepoConfig): ImportedFlake { + return importFlakeFromRepoConfig("gitlab", options); } /** @@ -113,11 +116,8 @@ export function importFromGitlab( * * See `importFlake` for details. */ -export function importFromSourcehut( - repo: string, - options: HostedGitServiceOptions = {}, -): ImportedFlake { - return importFlakeFromHostedGitService("sourcehut", repo, options); +export function importFromSourcehut(options: RepoConfig): ImportedFlake { + return importFlakeFromRepoConfig("sourcehut", options); } /** @@ -190,12 +190,11 @@ export function callFlake(flakeDir: string): ImportedFlake { return processFlake(nixRaw`(${callFlake} ${nixRaw(flakeDir)})`, flakeDir); } -function importFlakeFromHostedGitService( +function importFlakeFromRepoConfig( serviceName: string, - repo: string, - opts: HostedGitServiceOptions, + opts: RepoConfig, ): ImportedFlake { - if (!repo.match(/^[^/]+\/[^/]+$/)) { + if (!opts.repo.match(/^[^/]+\/[^/]+$/)) { throw new Error( "The `repo` of a hosted git service should match /", ); @@ -209,7 +208,7 @@ function importFlakeFromHostedGitService( return importFlake( serviceName + ":" + - repo + + opts.repo + (opts.revOrRef ? `/${opts.revOrRef}` : "") + (query ? `?${query}` : ""), );