Skip to content

Commit

Permalink
Add sindri init command for project scaffolding (initially gnark-only)
Browse files Browse the repository at this point in the history
This adds a new `sindri init` command and all of the boilerplate around
scaffolding out projects under the `templates/{{ circuitType }}/` directory. It
includes `gnark` as a proof of concept.

Merges #19
  • Loading branch information
sangaline authored Dec 2, 2023
1 parent 7ff3330 commit d863cf4
Show file tree
Hide file tree
Showing 16 changed files with 519 additions and 6 deletions.
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ENV NODE_ENV=development
ARG GID=1000
ARG UID=1000
RUN if [ "$GID" != "1000" ]; then \
if getent group $GID; then groupdel $(getent group $GID | cut -d: -f1); fi && \
groupmod -g $GID node && \
(find / -group 1000 -exec chgrp -h $GID {} \; || true) \
; fi
Expand All @@ -14,6 +15,7 @@ RUN if [ "$UID" != "1000" ]; then \
; fi

RUN apt-get update
RUN apt-get install --yes git

USER node

Expand Down
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
restart: unless-stopped
volumes:
- ./:/sindri/
- ~/.gitconfig:/home/node/.gitconfig
- yarn-cache:/home/node/.cache/yarn/

volumes:
Expand Down
8 changes: 3 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
"name": "sindri",
"version": "0.0.0",
"description": "The Sindri Labs JavaScript SDK and CLI tool.",
"files": [
"dist/",
"src/",
"sindri-manifest.json"
],
"files": ["dist/", "sindri-manifest.json", "src/", "templates/"],
"main": "dist/lib/index.js",
"module": "dist/lib/index.mjs",
"bin": {
Expand Down Expand Up @@ -55,6 +51,7 @@
"ignore-walk": "^6.0.4",
"jsonschema": "^1.4.1",
"lodash": "^4.17.21",
"nunjucks": "^3.2.4",
"pino": "^8.16.2",
"pino-pretty": "^10.2.3",
"rc": "^1.2.8",
Expand All @@ -67,6 +64,7 @@
"@types/ignore-walk": "^4.0.3",
"@types/lodash": "^4.14.202",
"@types/node": "^20.9.1",
"@types/nunjucks": "^3.2.6",
"@types/tar": "^6.1.10",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
Expand Down
1 change: 1 addition & 0 deletions src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const deployCommand = new Command()
// Upload the tarball.
let circuitId: string | undefined;
try {
logger.info("Circuit compilation initiated.");
const response = await CircuitsService.circuitCreate(formData);
circuitId = response.circuit_id;
logger.debug("/api/v1/circuit/create/ response:");
Expand Down
2 changes: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { argv, exit } from "process";
import { Command } from "@commander-js/extra-typings";

import { Config, configCommand } from "cli/config";
import { initCommand } from "cli/init";
import { deployCommand } from "cli/deploy";
import { lintCommand } from "cli/lint";
import { logger } from "cli/logging";
Expand All @@ -23,6 +24,7 @@ export const program = new Command()
false,
)
.addCommand(configCommand)
.addCommand(initCommand)
.addCommand(deployCommand)
.addCommand(lintCommand)
.addCommand(loginCommand)
Expand Down
193 changes: 193 additions & 0 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { execSync } from "child_process";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "fs";
import path from "path";
import process from "process";

import { Command } from "@commander-js/extra-typings";
import { confirm, input, select } from "@inquirer/prompts";

import { logger } from "cli/logging";
import { scaffoldDirectory } from "cli/utils";

export const initCommand = new Command()
.name("init")
.description("Initialize a new Sindri project.")
.argument(
"[directory]",
"The directory where the new project should be initialized.",
".",
)
.action(async (directory) => {
// Prepare the directory paths.
const directoryPath = path.resolve(directory);
const directoryName = path.basename(directoryPath);

// Ensure that the directory exists.
if (!existsSync(directoryPath)) {
mkdirSync(directoryPath, { recursive: true });
} else if (!statSync(directoryPath).isDirectory()) {
logger.warn(
`File "${directoryPath}" exists and is not a directory, aborting.`,
);
return process.exit(1);
}

// Check that the directory is empty.
const existingFiles = readdirSync(directoryPath);
if (existingFiles.length > 0) {
const proceed = await confirm({
message:
`The "${directoryPath}" directory already exists and contains files. Continuing will ` +
"overwrite your existing files. Are you *SURE* you would like to proceed?",
default: false,
});
if (!proceed) {
logger.info("Aborting.");
return process.exit(1);
}
}

// Collect common fields.
const circuitName = await input({
message: "Circuit Name:",
default: directoryName.replace(/[^-a-zA-Z0-9_]/g, "-"),
validate: (input): boolean | string => {
if (input.length === 0) {
return "You must specify a circuit name.";
}
if (!/^[-a-zA-Z0-9_]+$/.test(input)) {
return "Only alphanumeric characters, hyphens, and underscores are allowed.";
}
return true;
},
});
const circuitType: "circom" | "gnark" | "halo2" | "noir" = await select({
message: "Proving Framework:",
default: "gnark",
choices: [
{ name: "Circom", value: "circom" },
{ name: "Gnark", value: "gnark" },
{ name: "Halo2", value: "halo2" },
{ name: "Noir", value: "noir" },
],
});
const context: object = { circuitName, circuitType };

// Handle individual circuit types.
// Gnark.
if (circuitType === "gnark") {
const packageName = await input({
message: "Go Package Name:",
default: circuitName
.replace(/[^a-zA-Z0-9]/g, "")
.replace(/^[^a-z]*/, ""),
validate: (input): boolean | string => {
if (input.length === 0) {
return "You must specify a package name.";
}
if (!/^[a-z][a-z0-9]*$/.test(input)) {
return (
"Package names must begin with a lowercase letter and only be followed by " +
"alphanumeric characters."
);
}
return true;
},
});
const provingScheme: "groth16" = await select({
message: "Proving Scheme:",
default: "groth16",
choices: [{ name: "Groth16", value: "groth16" }],
});
const curveName:
| "bn254"
| "bls12-377"
| "bls12-381"
| "bls24-315"
| "bw6-633"
| "bw6-761" = await select({
message: "Curve Name:",
default: "bn254",
choices: [
{ name: "BN254", value: "bn254" },
{ name: "BLS12-377", value: "bls12-377" },
{ name: "BLS12-381", value: "bls12-381" },
{ name: "BLS24-315", value: "bls24-315" },
{ name: "BW6-633", value: "bw6-633" },
{ name: "BW6-761", value: "bw6-761" },
],
});
const gnarkCurveName = curveName.toUpperCase().replace("-", "_");
Object.assign(context, {
curveName,
gnarkCurveName,
packageName,
provingScheme,
});
} else {
logger.fatal(`Sorry, ${circuitType} is not yet supported.`);
return process.exit(1);
}

// Perform the scaffolding.
logger.info(
`Proceeding to generate scaffolded project in "${directoryPath}".`,
);
await scaffoldDirectory("common", directoryPath, context);
await scaffoldDirectory(circuitType, directoryPath, context);
// We use this in `common` right now to keep the directory tracked, we can remove this once we
// add files there.
const gitKeepFile = path.join(directoryPath, ".gitkeep");
if (existsSync(gitKeepFile)) {
rmSync(gitKeepFile);
}
logger.info("Project scaffolding successful.");

// Optionally, initialize a git repository.
let gitInstalled: boolean = false;
try {
execSync("git --version");
gitInstalled = true;
} catch {
logger.debug(
"Git is not installed, skipping git initialization questions.",
);
}
const gitAlreadyInitialized = existsSync(path.join(directoryPath, ".git"));
if (gitInstalled && !gitAlreadyInitialized) {
const initializeGit = await confirm({
message: `Would you like to initialize a git repository in "${directoryPath}"?`,
default: true,
});
if (initializeGit) {
logger.info(`Initializing git repository in "${directoryPath}".`);
try {
execSync("git init .", { cwd: directoryPath });
execSync("git add .", { cwd: directoryPath });
execSync("git commit -m 'Initial commit.'", { cwd: directoryPath });
logger.info("Successfully initialized git repository.");
} catch (error) {
logger.error("Error occurred while initializing the git repository.");
// Node.js doesn't seem to have a typed version of this error, so we assert it as
// something that's at least in the right ballpark.
const execError = error as NodeJS.ErrnoException & {
output: Buffer | string;
stderr: Buffer | string;
stdout: Buffer | string;
};
// The output is a really long list of numbers because it's a buffer, so truncate it.
const noisyKeys: Array<"output" | "stderr" | "stdout"> = [
"output",
"stderr",
"stdout",
];
noisyKeys.forEach((key) => {
if (key in execError) {
execError[key] = "<truncated>";
}
});
logger.error(execError);
}
}
}
});
109 changes: 108 additions & 1 deletion src/cli/utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { readdirSync, readFileSync } from "fs";
import { constants as fsConstants, readdirSync, readFileSync } from "fs";
import { access, mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";

import type { Schema } from "jsonschema";
import nunjucks from "nunjucks";
import type { PackageJson } from "type-fest";

import { logger } from "cli/logging";

const currentFilePath = fileURLToPath(import.meta.url);
const currentDirectoryPath = path.dirname(currentFilePath);

/**
* Checks whether or not a file (including directories) exists.
*
* @param filePath - The path of the file to check.
* @returns A boolean value indicating whether the file path exists.
*/
export async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}

/**
* Recursively searches for a file in the given directory and its parent directories.
*
Expand Down Expand Up @@ -86,3 +105,91 @@ export function locatePackageJson(): string {
}
return packageJsonPath;
}

/**
* Recursively copies and populates the contents of a template directory into an output directory.
*
* @param templateDirectory - The path to the template directory. Can be an absolute path or a
* subdirectory of the `templates/` directory in the project root.
* @param outputDirectory - The path to the output directory where the populated templates will be
* written.
* @param context - The nunjucks template context.
*/
export async function scaffoldDirectory(
templateDirectory: string,
outputDirectory: string,
context: object,
): Promise<void> {
// Normalize the paths and create the output directory if necessary.
const fullOutputDirectory = path.resolve(outputDirectory);
if (!(await fileExists(fullOutputDirectory))) {
await mkdir(fullOutputDirectory, { recursive: true });
}
const rootTemplateDirectory = findFileUpwards("templates");
if (!rootTemplateDirectory) {
throw new Error("Root template directory not found.");
}
const fullTemplateDirectory = path.isAbsolute(templateDirectory)
? templateDirectory
: path.resolve(rootTemplateDirectory, templateDirectory);
if (!(await fileExists(fullTemplateDirectory))) {
throw new Error(`The "${fullTemplateDirectory}" directory does not exist.`);
}

// Render a template using two syntaxes:
// * hacky `templateVARIABLENAME` syntax.
// * `nunjucks` template syntax.
const render = (content: string, context: object): string => {
let newContent = content;
// Poor man's templating with `templateVARIABLENAME`:
Object.entries(context).forEach(([key, value]) => {
if (typeof value !== "string") return;
newContent = newContent.replace(
new RegExp(`template${key.toUpperCase()}`, "gi"),
value,
);
});
// Real templating:
return nunjucks.renderString(newContent, context);
};

// Process the template directory recursively.
const processPath = async (
inputPath: string,
outputPath: string,
): Promise<void> => {
// Handle directories.
if ((await stat(inputPath)).isDirectory()) {
// Ensure the output directory exists.
if (!(await fileExists(outputPath))) {
await mkdir(outputPath, { recursive: true });
logger.debug(`Created directory: "${outputPath}"`);
}
if (!(await stat(outputPath)).isDirectory()) {
throw new Error(`"File ${outputPath} exists and is not a directory.`);
}

// Process all files in the directory.
const files = await readdir(inputPath);
await Promise.all(
files.map(async (file) => {
// Render the filename so that `outputPath` always corresponds to the true output path.
// This handles situations like `{{ circuitName }}.go` where there's a variable in the name.
const populatedFile = render(file, context);
await processPath(
path.join(inputPath, file),
path.join(outputPath, populatedFile),
);
}),
);
return;
}

// Handle files, rendering them and writing them out.
const template = await readFile(inputPath, { encoding: "utf-8" });
const renderedTemplate = render(template, context);
await writeFile(outputPath, renderedTemplate, { encoding: "utf-8" });
logger.debug(`Rendered "${inputPath}" template to "${outputPath}".`);
};
await processPath(fullTemplateDirectory, fullOutputDirectory);
}
Loading

0 comments on commit d863cf4

Please sign in to comment.