Skip to content

Commit 5a5af09

Browse files
authored
feat: mops build subcommand (#331)
* Add 'npm run mops' command and set up formatter for VS Code * Set up boilerplate * Remove .vscode from gitignore * Rename 'ZenVoich/mops' repository to 'dfinity/mops' * Simplify precommit hook * Progress * Implement building dfx canisters * Improve output format * Misc * Simplify * Use 'interface' in place of 'type' * Add support for mops.toml [canisters] and [build] config * Remove '--dfx' option for 'mops build' (initially just 'mops.toml' config) * Show moc command path/args with --verbose * Fix * Add more .js suffixes to imports
1 parent e5317f2 commit 5a5af09

11 files changed

Lines changed: 3830 additions & 202 deletions

File tree

cli/cli.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { installAll } from "./commands/install/install-all.js";
1919
import { search } from "./commands/search.js";
2020
import { add } from "./commands/add.js";
2121
import { cacheSize, cleanCache, show } from "./cache.js";
22+
import { build, DEFAULT_BUILD_OUTPUT_DIR } from "./commands/build.js";
2223
import { test } from "./commands/test/test.js";
2324
import { template } from "./commands/template.js";
2425
import { remove } from "./commands/remove.js";
@@ -47,6 +48,7 @@ import {
4748
import { format } from "./commands/format.js";
4849
import { docs } from "./commands/docs.js";
4950
import { docsCoverage } from "./commands/docs-coverage.js";
51+
import { resolve } from "node:path";
5052

5153
declare global {
5254
// eslint-disable-next-line no-var
@@ -57,6 +59,12 @@ declare global {
5759

5860
events.setMaxListeners(20);
5961

62+
// Change working directory for `npm run mops`
63+
let cwd = process.env["MOPS_CWD"];
64+
if (cwd) {
65+
process.chdir(resolve(cwd));
66+
}
67+
6068
let networkFile = getNetworkFile();
6169
if (fs.existsSync(networkFile)) {
6270
globalThis.MOPS_NETWORK = fs.readFileSync(networkFile).toString() || "ic";
@@ -252,6 +260,32 @@ program
252260
}
253261
});
254262

263+
// build
264+
program
265+
.command("build [canisters...]")
266+
.description("Build a canister")
267+
.addOption(new Option("--verbose", "Verbose console output"))
268+
.addOption(
269+
new Option("--output, -o <output>", "Output directory").default(
270+
DEFAULT_BUILD_OUTPUT_DIR,
271+
),
272+
)
273+
.allowUnknownOption(true) // TODO: restrict unknown before "--"
274+
.action(async (canisters, options, command) => {
275+
checkConfigFile(true);
276+
const extraArgsIndex = command.args.indexOf("--");
277+
await installAll({
278+
silent: true,
279+
lock: "ignore",
280+
installFromLockFile: true,
281+
});
282+
await build(canisters.length ? canisters : undefined, {
283+
...options,
284+
extraArgs:
285+
extraArgsIndex !== -1 ? command.args.slice(extraArgsIndex + 1) : [],
286+
});
287+
});
288+
255289
// test
256290
program
257291
.command("test [filter]")
@@ -630,7 +664,7 @@ docsCommand
630664
.description("Generate documentation for Motoko code")
631665
.addOption(new Option("--source <source>", "Source directory").default("src"))
632666
.addOption(
633-
new Option("--output <output>", "Output directory").default("docs"),
667+
new Option("--output, -o <output>", "Output directory").default("docs"),
634668
)
635669
.addOption(
636670
new Option("--format <format>", "Output format")

cli/commands/build.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import chalk from "chalk";
2+
import { execa } from "execa";
3+
import { exists } from "fs-extra";
4+
import { mkdir } from "node:fs/promises";
5+
import { join } from "node:path";
6+
import { getMocPath } from "../helpers/get-moc-path.js";
7+
import { readConfig } from "../mops.js";
8+
import { CanisterConfig } from "../types.js";
9+
import { sourcesArgs } from "./sources.js";
10+
11+
export interface BuildOptions {
12+
outputDir: string;
13+
verbose: boolean;
14+
extraArgs: string[];
15+
}
16+
17+
export const DEFAULT_BUILD_OUTPUT_DIR = ".mops/.build";
18+
19+
export async function build(
20+
canisterNames: string[] | undefined,
21+
options: Partial<BuildOptions>,
22+
): Promise<void> {
23+
if (canisterNames?.length == 0) {
24+
throw new Error("No canisters specified to build");
25+
}
26+
27+
let outputDir = options.outputDir ?? DEFAULT_BUILD_OUTPUT_DIR;
28+
let mocPath = getMocPath();
29+
let canisters: Record<string, CanisterConfig> = {};
30+
let config = readConfig();
31+
if (config.canisters) {
32+
canisters =
33+
Object.fromEntries(
34+
Object.entries(config.canisters).map(([name, c]) =>
35+
typeof c === "string" ? [name, { main: c }] : [name, c],
36+
),
37+
) ?? {};
38+
}
39+
if (!Object.keys(canisters).length) {
40+
throw new Error(`No Motoko canisters found in mops.toml configuration`);
41+
}
42+
43+
if (canisterNames) {
44+
canisterNames = canisterNames.filter((name) => name in canisters);
45+
if (canisterNames.length === 0) {
46+
throw new Error("No valid canister names specified");
47+
}
48+
for (let name of canisterNames) {
49+
if (!(name in canisters)) {
50+
throw new Error(
51+
`Motoko canister '${name}' not found in mops.toml configuration`,
52+
);
53+
}
54+
}
55+
}
56+
57+
if (!(await exists(outputDir))) {
58+
await mkdir(outputDir, { recursive: true });
59+
}
60+
for (let [canisterName, canister] of Object.entries(canisters)) {
61+
options.verbose && console.time(`build canister ${canisterName}`);
62+
console.log(chalk.blue("build canister"), chalk.bold(canisterName));
63+
let motokoPath = canister.main;
64+
if (!motokoPath) {
65+
throw new Error(`No main file is specified for canister ${canisterName}`);
66+
}
67+
let args = [
68+
"-c",
69+
"--idl",
70+
"-o",
71+
join(outputDir, `${canisterName}.wasm`),
72+
motokoPath,
73+
...(options.extraArgs ?? []),
74+
...(await sourcesArgs()).flat(),
75+
];
76+
if (config.build?.args) {
77+
if (typeof config.build.args === "string") {
78+
throw new Error(
79+
`[build] config 'args' should be an array of strings in mops.toml config file`,
80+
);
81+
}
82+
args.push(...config.build.args);
83+
}
84+
if (canister.args) {
85+
if (typeof canister.args === "string") {
86+
throw new Error(
87+
`Canister config 'args' should be an array of strings for canister ${canisterName}`,
88+
);
89+
}
90+
args.push(...canister.args);
91+
}
92+
try {
93+
if (options.verbose) {
94+
console.log(chalk.gray(mocPath, JSON.stringify(args)));
95+
}
96+
const result = await execa(mocPath, args, {
97+
stdio: options.verbose ? "inherit" : "pipe",
98+
reject: false,
99+
});
100+
101+
if (result.exitCode !== 0) {
102+
console.error(
103+
chalk.red(`Error: Failed to build canister ${canisterName}`),
104+
);
105+
if (!options.verbose) {
106+
if (result.stderr) {
107+
console.error(chalk.red(result.stderr));
108+
}
109+
if (result.stdout?.trim()) {
110+
console.error(chalk.yellow("Build output:"));
111+
console.error(result.stdout);
112+
}
113+
}
114+
// throw new Error(
115+
// `Build failed for canister ${canisterName} (exit code: ${result.exitCode})`,
116+
// );
117+
process.exit(1);
118+
}
119+
120+
if (options.verbose && result.stdout && result.stdout.trim()) {
121+
console.log(result.stdout);
122+
}
123+
} catch (error: any) {
124+
if (error.message?.includes("Build failed for canister")) {
125+
throw error;
126+
}
127+
console.error(
128+
chalk.red(`Error while compiling canister ${canisterName}`),
129+
);
130+
if (error.message) {
131+
console.error(chalk.red(`Details: ${error.message}`));
132+
}
133+
134+
throw new Error(`Build execution failed for canister ${canisterName}`);
135+
}
136+
options.verbose && console.timeEnd(`build canister ${canisterName}`);
137+
}
138+
139+
console.log(
140+
chalk.green(`\n✓ Built ${Object.keys(canisters).length} canisters`),
141+
);
142+
}

cli/commands/sources.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import {
1010
} from "../mops.js";
1111
import { resolvePackages } from "../resolve-packages.js";
1212

13-
export async function sources({
13+
export async function sourcesArgs({
1414
conflicts = "ignore" as "warning" | "error" | "ignore",
1515
cwd = process.cwd(),
16-
} = {}) {
16+
} = {}): Promise<string[][]> {
1717
if (!checkConfigFile()) {
1818
return [];
1919
}
@@ -53,7 +53,14 @@ export async function sources({
5353
pkgBaseDir = pkgDir;
5454
}
5555

56-
return `--package ${name} ${pkgBaseDir}`;
56+
return ["--package", name, pkgBaseDir];
5757
})
5858
.filter((x) => x != null);
5959
}
60+
61+
export async function sources({
62+
conflicts = "ignore" as "warning" | "error" | "ignore",
63+
cwd = process.cwd(),
64+
} = {}): Promise<string[]> {
65+
return (await sourcesArgs({ conflicts, cwd })).map((args) => args.join(" "));
66+
}

cli/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
"node": ">=18.0.0"
2929
},
3030
"scripts": {
31-
"mops": "tsx ./cli.ts",
3231
"build": "npm run prepare && npm run bundle",
3332
"dist": "tsc",
3433
"bundle": "rm -rf ./bundle && bun build ./cli.ts --outdir ./bundle --target node --minify --external @napi-rs/lzma --external fsevents --format esm --define '__dirname=import.meta.dirname' && npm run bundle:fix && npm run bundle:copy && npm run bundle:package-json && npm run bundle:tar",

cli/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,19 @@ export type Config = {
1919
"dev-dependencies"?: Dependencies;
2020
toolchain?: Toolchain;
2121
requirements?: Requirements;
22+
canisters?: Record<string, string | CanisterConfig>;
23+
build?: {
24+
outputDir?: string;
25+
args?: string[];
26+
};
2227
// format ?: Format;
2328
};
2429

30+
export type CanisterConfig = {
31+
main: string;
32+
args?: string[];
33+
};
34+
2535
export type Dependencies = Record<string, Dependency>;
2636

2737
export type Dependency = {

0 commit comments

Comments
 (0)