@flare-desktop/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { FlareError } from "../lib/errors.js";
5
+ import { placeholderIcon } from "../lib/icons.js";
6
+ import { isValidAppName, toKebabCase } from "../lib/project.js";
7
+ import { writePng } from "../lib/png.js";
8
+ import { VERSION } from "../version.js";
9
+ import {
10
+ cmakeTemplate,
11
+ configTemplate,
12
+ gitignoreTemplate,
13
+ indexHtmlTemplate,
14
+ packageJsonTemplate,
15
+ readmeTemplate,
16
+ scriptTemplate,
17
+ sourceTemplate,
18
+ styleTemplate
19
+ } from "../templates/project.js";
20
+
21
+ function write(file, content) {
22
+ writeFileSync(file, content, "utf8");
23
+ }
24
+
25
+ export async function initProject({
26
+ name = "flare-app",
27
+ directory,
28
+ baseDirectory = ".",
29
+ identifier,
30
+ appVersion = "0.1.0",
31
+ coreVersion = VERSION,
32
+ force = false,
33
+ logger
34
+ }) {
35
+ const projectName = toKebabCase(name);
36
+ if (!isValidAppName(projectName)) {
37
+ throw new FlareError(
38
+ `'${name}' is not a valid project name; use lowercase letters, digits, dashes and underscores`
39
+ );
40
+ }
41
+
42
+ const appIdentifier = identifier || `com.example.${projectName}`;
43
+ if (!appIdentifier.includes(".")) {
44
+ throw new FlareError(`identifier '${appIdentifier}' must contain at least one dot`);
45
+ }
46
+
47
+ const targetDirectory = path.resolve(baseDirectory, directory || projectName);
48
+ if (existsSync(targetDirectory) && !force) {
49
+ throw new FlareError(`directory '${targetDirectory}' already exists`);
50
+ }
51
+
52
+ mkdirSync(targetDirectory, { recursive: true });
53
+ mkdirSync(path.join(targetDirectory, "source"), { recursive: true });
54
+ mkdirSync(path.join(targetDirectory, "frontend"), { recursive: true });
55
+ mkdirSync(path.join(targetDirectory, "icons"), { recursive: true });
56
+
57
+ const files = [
58
+ {
59
+ file: "flare.config.json",
60
+ content: `${JSON.stringify(
61
+ configTemplate({ name: projectName, identifier: appIdentifier, version: appVersion }),
62
+ null,
63
+ 2
64
+ )}\n`
65
+ },
66
+ {
67
+ file: "package.json",
68
+ content: `${JSON.stringify(
69
+ packageJsonTemplate({ name: projectName, version: appVersion, cliVersion: coreVersion }),
70
+ null,
71
+ 2
72
+ )}\n`
73
+ },
74
+ {
75
+ file: "CMakeLists.txt",
76
+ content: cmakeTemplate({ name: projectName, version: appVersion, coreVersion })
77
+ },
78
+ {
79
+ file: "source/main.cpp",
80
+ content: sourceTemplate({ name: projectName, identifier: appIdentifier, version: appVersion })
81
+ },
82
+ { file: "frontend/index.html", content: indexHtmlTemplate(projectName) },
83
+ { file: "frontend/style.css", content: styleTemplate() },
84
+ { file: "frontend/main.js", content: scriptTemplate() },
85
+ { file: ".gitignore", content: gitignoreTemplate() },
86
+ { file: "README.md", content: readmeTemplate(projectName) }
87
+ ];
88
+
89
+ for (const entry of files) {
90
+ write(path.join(targetDirectory, entry.file), entry.content);
91
+ }
92
+
93
+ writeFileSync(path.join(targetDirectory, "icons", "icon.png"), writePng(placeholderIcon()));
94
+ writeFileSync(path.join(targetDirectory, "icons", "32x32.png"), writePng(placeholderIcon(32)));
95
+
96
+ const written = [...files.map((entry) => entry.file), "icons/icon.png", "icons/32x32.png"];
97
+
98
+ if (logger) {
99
+ logger.info(`created project '${projectName}' in '${targetDirectory}'`);
100
+ logger.info("");
101
+ logger.info("next steps:");
102
+ logger.step(`cd ${path.relative(process.cwd(), targetDirectory) || "."}`);
103
+ logger.step("npm install");
104
+ logger.step("npm run dev");
105
+ }
106
+
107
+ return { directory: targetDirectory, name: projectName, identifier: appIdentifier, files: written };
108
+ }
@@ -0,0 +1,35 @@
1
+ import { loadProject } from "../lib/project.js";
2
+
3
+ export async function inspectCommand(context) {
4
+ const { config, files } = loadProject({
5
+ projectDirectory: context.projectDirectory,
6
+ configFile: context.flags.config,
7
+ environment: context.flags.environment
8
+ });
9
+
10
+ if (context.flags.json) {
11
+ context.logger.info(JSON.stringify(config, null, 2));
12
+ return 0;
13
+ }
14
+
15
+ context.logger.info(`resolved ${files.length} configuration layer(s)`);
16
+ for (const file of files) {
17
+ const name = file.split(/[\\/]/).pop();
18
+ context.logger.step(`${file} (${layerOf(name, context.flags.environment)})`);
19
+ }
20
+ context.logger.info("configuration is valid");
21
+ return 0;
22
+ }
23
+
24
+ function layerOf(fileName, environment) {
25
+ const stem = fileName.replace(/\.[^.]+$/, "").toLowerCase();
26
+ if (stem.endsWith(".local")) return "local";
27
+ const parts = stem.split(".");
28
+ if (parts.length >= 2) {
29
+ const layer = parts[parts.length - 1];
30
+ if (["linux", "macos", "windows"].includes(layer)) return "platform";
31
+ if (["development", "production", "staging"].includes(layer)) return "environment";
32
+ if (environment && layer === environment.toLowerCase()) return "environment";
33
+ }
34
+ return "base";
35
+ }
package/src/help.js ADDED
@@ -0,0 +1,32 @@
1
+ import { COMMANDS } from "./lib/commands.js";
2
+ import { VERSION } from "./version.js";
3
+
4
+ export function helpText() {
5
+ const lines = [
6
+ `flare ${VERSION}`,
7
+ "",
8
+ "usage: flare <command> [options]",
9
+ "",
10
+ "commands:"
11
+ ];
12
+
13
+ for (const command of COMMANDS) {
14
+ lines.push(` ${command.usage.padEnd(26)}${command.description}`);
15
+ }
16
+
17
+ lines.push(
18
+ "",
19
+ "options:",
20
+ " --project=<path> project directory (default: .)",
21
+ " --config=<path> configuration file (default: flare.config.json)",
22
+ " --environment=<name> add the environment configuration layer",
23
+ " --bundles=<list> bundle targets, for example nsis,msi,deb",
24
+ " --out=<path> bundle output directory (default: target/bundle)",
25
+ " --json machine readable output",
26
+ " --verbose, -v enable debug logging",
27
+ " --version, -V print the cli version",
28
+ " --help, -h print this help"
29
+ );
30
+
31
+ return `${lines.join("\n")}\n`;
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ export { createLogger, run } from "./cli.js";
2
+ export { initProject } from "./commands/init.js";
3
+ export { VERSION } from "./version.js";
4
+ export {
5
+ DEFAULT_CONFIG_FILE,
6
+ DEFAULT_SCHEMA_URL,
7
+ binaryName,
8
+ defaults,
9
+ loadProject,
10
+ mergeConfig,
11
+ normalizeConfig,
12
+ platformName,
13
+ productName,
14
+ resolveConfigFiles,
15
+ validateConfig
16
+ } from "./lib/project.js";
17
+ export { FlareError } from "./lib/errors.js";
@@ -0,0 +1,65 @@
1
+ const VALUE_FLAGS = new Set([
2
+ "project",
3
+ "config",
4
+ "environment",
5
+ "target",
6
+ "bundles",
7
+ "out",
8
+ "format",
9
+ "identifier",
10
+ "app-version",
11
+ "dev-timeout"
12
+ ]);
13
+
14
+ const SHORT_FLAGS = {
15
+ v: "verbose",
16
+ h: "help",
17
+ V: "version"
18
+ };
19
+
20
+ export function camelCase(name) {
21
+ return name.replace(/-([a-z0-9])/g, (_, character) => character.toUpperCase());
22
+ }
23
+
24
+ export function parseArguments(argv) {
25
+ const flags = {};
26
+ const positionals = [];
27
+
28
+ for (let index = 0; index < argv.length; index += 1) {
29
+ const argument = argv[index];
30
+
31
+ if (argument === "--") {
32
+ positionals.push(...argv.slice(index + 1));
33
+ break;
34
+ }
35
+
36
+ if (argument.startsWith("--")) {
37
+ const body = argument.slice(2);
38
+ const separator = body.indexOf("=");
39
+ if (separator !== -1) {
40
+ flags[camelCase(body.slice(0, separator))] = body.slice(separator + 1);
41
+ continue;
42
+ }
43
+ if (body.startsWith("no-")) {
44
+ flags[camelCase(body.slice(3))] = false;
45
+ continue;
46
+ }
47
+ if (VALUE_FLAGS.has(body)) {
48
+ flags[camelCase(body)] = argv[index + 1] ?? "";
49
+ index += 1;
50
+ continue;
51
+ }
52
+ flags[camelCase(body)] = true;
53
+ continue;
54
+ }
55
+
56
+ if (argument.startsWith("-") && argument.length === 2 && SHORT_FLAGS[argument[1]]) {
57
+ flags[SHORT_FLAGS[argument[1]]] = true;
58
+ continue;
59
+ }
60
+
61
+ positionals.push(argument);
62
+ }
63
+
64
+ return { flags, positionals };
65
+ }
@@ -0,0 +1,29 @@
1
+ import { buildDirectory, buildProject, configureProject, findExecutable } from "./cmake.js";
2
+ import { FlareError } from "./errors.js";
3
+ import { binaryName, frontendDirectory } from "./project.js";
4
+ import { runShell } from "./process.js";
5
+
6
+ export async function runHook(command, { cwd, env, logger, label }) {
7
+ if (!command) return;
8
+ logger.info(`${label}: ${command}`);
9
+ const result = await runShell(command, { cwd, env });
10
+ if (result.code !== 0) throw new FlareError(`${label} failed with code ${result.code}`);
11
+ }
12
+
13
+ export async function compileApplication({ projectDirectory, config, mode, env, logger }) {
14
+ const directory = buildDirectory(projectDirectory, mode);
15
+ await configureProject(projectDirectory, {
16
+ directory,
17
+ mode,
18
+ env,
19
+ logger,
20
+ frontendDirectory: frontendDirectory(projectDirectory, config)
21
+ });
22
+ await buildProject(projectDirectory, { directory, mode, env });
23
+ const binary = findExecutable(directory, binaryName(config));
24
+ if (!binary) {
25
+ throw new FlareError(`built binary '${binaryName(config)}' was not found in '${directory}'`);
26
+ }
27
+ logger.debug(`executable ${binary}`);
28
+ return { binary, directory };
29
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { FlareError } from "./errors.js";
5
+ import { capture, firstLine, run } from "./process.js";
6
+
7
+ export function cmakeVersion() {
8
+ const result = capture("cmake", ["--version"]);
9
+ if (result.error || result.status !== 0) return null;
10
+ return firstLine(result.stdout);
11
+ }
12
+
13
+ export function ninjaVersion() {
14
+ const result = capture("ninja", ["--version"]);
15
+ if (result.error || result.status !== 0) return null;
16
+ return firstLine(result.stdout);
17
+ }
18
+
19
+ export function compilerVersion() {
20
+ const candidates =
21
+ process.platform === "win32" ? [["cl", []], ["g++", ["--version"]], ["clang++", ["--version"]]] : [["c++", ["--version"]], ["clang++", ["--version"]], ["g++", ["--version"]]];
22
+ for (const [command, args] of candidates) {
23
+ const result = capture(command, args);
24
+ if (!result.error && result.status === 0) return firstLine(result.stdout);
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function generatorArguments() {
30
+ if (process.platform === "win32") return [];
31
+ return ninjaVersion() ? ["-G", "Ninja"] : [];
32
+ }
33
+
34
+ export function buildDirectory(projectDirectory, mode) {
35
+ return path.join(projectDirectory, "build", mode === "Debug" ? "debug" : "release");
36
+ }
37
+
38
+ export async function configureProject(projectDirectory, { directory, mode, env, logger, frontendDirectory }) {
39
+ if (!cmakeVersion()) throw new FlareError("cmake was not found in PATH");
40
+ const outputDirectory = path.join(directory, "bin");
41
+ const configuration = mode.toUpperCase();
42
+ const args = [
43
+ "-S",
44
+ projectDirectory,
45
+ "-B",
46
+ directory,
47
+ ...generatorArguments(),
48
+ `-DCMAKE_BUILD_TYPE=${mode}`,
49
+ `-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=${outputDirectory}`,
50
+ `-DCMAKE_RUNTIME_OUTPUT_DIRECTORY_${configuration}=${outputDirectory}`,
51
+ "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
52
+ `-DFLARE_FRONTEND_DIR=${frontendDirectory ?? path.join(projectDirectory, "frontend")}`,
53
+ "-DFLARE_BUILD_TOOLS=OFF",
54
+ "-DFLARE_BUILD_EXAMPLES=OFF",
55
+ "-DFLARE_BUILD_TESTS=OFF",
56
+ "-DFLARE_INSTALL=OFF"
57
+ ];
58
+ logger.debug(`cmake ${args.join(" ")}`);
59
+ const result = await run("cmake", args, { cwd: projectDirectory, env });
60
+ if (result.code !== 0) throw new FlareError("cmake configuration failed");
61
+ }
62
+
63
+ export async function buildProject(projectDirectory, { directory, mode, target, env }) {
64
+ const args = ["--build", directory, "--config", mode];
65
+ if (target) args.push("--target", target);
66
+ const result = await run("cmake", args, { cwd: projectDirectory, env });
67
+ if (result.code !== 0) throw new FlareError("compilation failed");
68
+ }
69
+
70
+ export function executableNames(name) {
71
+ if (process.platform === "win32") return [`${name}.exe`];
72
+ return [name];
73
+ }
74
+
75
+ export function findExecutable(directory, name) {
76
+ const names = executableNames(name);
77
+ const candidates = [
78
+ path.join(directory, "bin"),
79
+ path.join(directory, "bin", "Release"),
80
+ path.join(directory, "bin", "Debug"),
81
+ directory,
82
+ path.join(directory, "Release"),
83
+ path.join(directory, "Debug")
84
+ ];
85
+
86
+ for (const candidate of candidates) {
87
+ for (const fileName of names) {
88
+ const file = path.join(candidate, fileName);
89
+ if (existsSync(file)) return file;
90
+ }
91
+ }
92
+
93
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
94
+ if (!entry.isDirectory()) continue;
95
+ const nested = path.join(directory, entry.name);
96
+ for (const fileName of names) {
97
+ if (existsSync(path.join(nested, fileName))) return path.join(nested, fileName);
98
+ }
99
+ }
100
+
101
+ return null;
102
+ }
@@ -0,0 +1,15 @@
1
+ export const COMMANDS = [
2
+ { name: "init", usage: "init [name]", description: "scaffold a new flare project" },
3
+ { name: "dev", usage: "dev", description: "build and run the application in development mode" },
4
+ { name: "build", usage: "build [--debug]", description: "build the application" },
5
+ { name: "bundle", usage: "bundle", description: "build and prepare the platform bundle targets" },
6
+ { name: "icon", usage: "icon <source.png>", description: "generate the icon set from a square png" },
7
+ { name: "info", usage: "info", description: "print project and platform information" },
8
+ { name: "inspect", usage: "inspect", description: "print the resolved configuration layers" },
9
+ { name: "doctor", usage: "doctor", description: "check the toolchain and the configuration" },
10
+ { name: "completions", usage: "completions <shell>", description: "print shell completions (bash, zsh, fish, powershell)" },
11
+ { name: "version", usage: "version", description: "print the flare cli version" },
12
+ { name: "help", usage: "help", description: "print this help" }
13
+ ];
14
+
15
+ export const COMMAND_NAMES = COMMANDS.map((command) => command.name);
@@ -0,0 +1,7 @@
1
+ export class FlareError extends Error {
2
+ constructor(message, exitCode = 1) {
3
+ super(message);
4
+ this.name = "FlareError";
5
+ this.exitCode = exitCode;
6
+ }
7
+ }
@@ -0,0 +1,127 @@
1
+ import { FlareError } from "./errors.js";
2
+ import { resizePng, writePng } from "./png.js";
3
+
4
+ export function placeholderIcon(size = 512) {
5
+ const data = Buffer.alloc(size * size * 4);
6
+ const center = (size - 1) / 2;
7
+ const radius = size * 0.46;
8
+ for (let y = 0; y < size; y += 1) {
9
+ for (let x = 0; x < size; x += 1) {
10
+ const target = (y * size + x) * 4;
11
+ const distance = Math.hypot(x - center, y - center) / radius;
12
+ const glow = Math.max(0, Math.min(1, 1 - Math.abs(distance - 0.55) * 1.6));
13
+ const edge = distance <= 1 ? 1 : 0;
14
+ data[target] = edge ? Math.round(44 + 172 * glow) : 0;
15
+ data[target + 1] = edge ? Math.round(35 + 132 * glow) : 0;
16
+ data[target + 2] = edge ? Math.round(40 + 137 * glow) : 0;
17
+ data[target + 3] = edge ? 255 : 0;
18
+ }
19
+ }
20
+ return { width: size, height: size, data };
21
+ }
22
+
23
+ const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256];
24
+ const ICNS_TYPES = {
25
+ 16: "icp4",
26
+ 32: "icp5",
27
+ 64: "icp6",
28
+ 128: "ic07",
29
+ 256: "ic08",
30
+ 512: "ic09",
31
+ 1024: "ic10"
32
+ };
33
+
34
+ function encodeDib(image) {
35
+ const { width, height, data } = image;
36
+ const header = Buffer.alloc(40);
37
+ const xor = Buffer.alloc(width * height * 4);
38
+ header.writeUInt32LE(40, 0);
39
+ header.writeInt32LE(width, 4);
40
+ header.writeInt32LE(height * 2, 8);
41
+ header.writeUInt16LE(1, 12);
42
+ header.writeUInt16LE(32, 14);
43
+ header.writeUInt32LE(0, 16);
44
+ header.writeUInt32LE(xor.length, 20);
45
+
46
+ for (let y = 0; y < height; y += 1) {
47
+ const source = (height - 1 - y) * width * 4;
48
+ const target = y * width * 4;
49
+ for (let x = 0; x < width; x += 1) {
50
+ xor[target + x * 4] = data[source + x * 4 + 2];
51
+ xor[target + x * 4 + 1] = data[source + x * 4 + 1];
52
+ xor[target + x * 4 + 2] = data[source + x * 4];
53
+ xor[target + x * 4 + 3] = data[source + x * 4 + 3];
54
+ }
55
+ }
56
+
57
+ const maskStride = Math.ceil(width / 32) * 4;
58
+ const mask = Buffer.alloc(maskStride * height);
59
+ header.writeUInt32LE(xor.length + mask.length, 20);
60
+ return Buffer.concat([header, xor, mask]);
61
+ }
62
+
63
+ export function encodeIco(image) {
64
+ const entries = ICO_SIZES.map((size) => {
65
+ const resized = resizePng(image, size, size);
66
+ return { size, payload: size >= 128 ? writePng(resized) : encodeDib(resized) };
67
+ });
68
+
69
+ const header = Buffer.alloc(6);
70
+ header.writeUInt16LE(0, 0);
71
+ header.writeUInt16LE(1, 2);
72
+ header.writeUInt16LE(entries.length, 4);
73
+
74
+ const directory = Buffer.alloc(entries.length * 16);
75
+ let offset = 6 + directory.length;
76
+ entries.forEach((entry, index) => {
77
+ const target = index * 16;
78
+ directory[target] = entry.size === 256 ? 0 : entry.size;
79
+ directory[target + 1] = entry.size === 256 ? 0 : entry.size;
80
+ directory[target + 2] = 0;
81
+ directory[target + 3] = 0;
82
+ directory.writeUInt16LE(1, target + 4);
83
+ directory.writeUInt16LE(32, target + 6);
84
+ directory.writeUInt32LE(entry.payload.length, target + 8);
85
+ directory.writeUInt32LE(offset, target + 12);
86
+ offset += entry.payload.length;
87
+ });
88
+
89
+ return Buffer.concat([header, directory, ...entries.map((entry) => entry.payload)]);
90
+ }
91
+
92
+ export function encodeIcns(image) {
93
+ const chunks = Object.entries(ICNS_TYPES).map(([size, type]) => {
94
+ const payload = writePng(resizePng(image, Number(size), Number(size)));
95
+ const header = Buffer.alloc(8);
96
+ header.write(type, 0, 4, "ascii");
97
+ header.writeUInt32BE(payload.length + 8, 4);
98
+ return Buffer.concat([header, payload]);
99
+ });
100
+
101
+ const header = Buffer.alloc(8);
102
+ header.write("icns", 0, 4, "ascii");
103
+ header.writeUInt32BE(8 + chunks.reduce((total, chunk) => total + chunk.length, 0), 4);
104
+ return Buffer.concat([header, ...chunks]);
105
+ }
106
+
107
+ export function buildIconSet(image) {
108
+ if (image.width !== image.height) {
109
+ throw new FlareError(`icon source must be square, got ${image.width}x${image.height}`);
110
+ }
111
+
112
+ const variants = [
113
+ { name: "32x32.png", size: 32 },
114
+ { name: "128x128.png", size: 128 },
115
+ { name: "128x128@2x.png", size: 256 },
116
+ { name: "icon.png", size: 512 }
117
+ ];
118
+
119
+ return [
120
+ ...variants.map(({ name, size }) => ({
121
+ name,
122
+ data: writePng(resizePng(image, size, size))
123
+ })),
124
+ { name: "icon.ico", data: encodeIco(image) },
125
+ { name: "icon.icns", data: encodeIcns(image) }
126
+ ];
127
+ }
@@ -0,0 +1,19 @@
1
+ export function createLogger({ verbose = false } = {}) {
2
+ return {
3
+ info(message) {
4
+ process.stdout.write(`${message}\n`);
5
+ },
6
+ step(message) {
7
+ process.stdout.write(` ${message}\n`);
8
+ },
9
+ warn(message) {
10
+ process.stderr.write(`warning: ${message}\n`);
11
+ },
12
+ error(message) {
13
+ process.stderr.write(`error: ${message}\n`);
14
+ },
15
+ debug(message) {
16
+ if (verbose) process.stderr.write(`debug: ${message}\n`);
17
+ }
18
+ };
19
+ }