@highstate/cli 0.13.2 → 0.14.1

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,189 @@
1
+ import type { PackageManagerName } from "nypm"
2
+ import { access, mkdir, readdir } from "node:fs/promises"
3
+ import { basename, resolve } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { input, select } from "@inquirer/prompts"
6
+ import { Command, Option } from "clipanion"
7
+ import { installDependencies } from "nypm"
8
+ import { generateFromTemplate, logger } from "../shared"
9
+
10
+ export class InitCommand extends Command {
11
+ static paths = [["init"]]
12
+
13
+ static usage = Command.Usage({
14
+ description: "Initializes a new Highstate project.",
15
+ })
16
+
17
+ pathOption = Option.String("--path,-p", {
18
+ description: "The path where the project should be initialized.",
19
+ })
20
+
21
+ packageManager = Option.String("--package-manager", {
22
+ description: "The package manager to use (npm, yarn, pnpm).",
23
+ })
24
+
25
+ name = Option.String("--name", {
26
+ description: "The project name.",
27
+ })
28
+
29
+ private static readonly defaultPlatformVersion = "0.14.0"
30
+ private static readonly defaultLibraryVersion = "0.14.0"
31
+
32
+ async execute(): Promise<void> {
33
+ const availablePackageManagers = await detectAvailablePackageManagers(["npm", "pnpm", "yarn"])
34
+ if (availablePackageManagers.length === 0) {
35
+ throw new Error("no supported package managers found in PATH (npm, pnpm, yarn)")
36
+ }
37
+
38
+ const destinationPath = await resolveDestinationPath(this.pathOption)
39
+ const defaultName = basename(destinationPath)
40
+
41
+ const projectName = await resolveProjectName(this.name, defaultName)
42
+
43
+ const selectedPackageManager = await resolvePackageManager(
44
+ this.packageManager,
45
+ availablePackageManagers,
46
+ )
47
+
48
+ const templatePath = resolveTemplatePath()
49
+
50
+ await mkdir(destinationPath, { recursive: true })
51
+
52
+ const isEmptyOrMissing = await isEmptyDirectory(destinationPath)
53
+ if (!isEmptyOrMissing) {
54
+ throw new Error(`destination path is not empty: ${destinationPath}`)
55
+ }
56
+
57
+ logger.info("initializing highstate project in %s", destinationPath)
58
+
59
+ await generateFromTemplate(templatePath, destinationPath, {
60
+ projectName,
61
+ packageName: projectName,
62
+ platformVersion: InitCommand.defaultPlatformVersion,
63
+ libraryVersion: InitCommand.defaultLibraryVersion,
64
+ isPnpm: selectedPackageManager === "pnpm" ? "true" : "",
65
+ isYarn: selectedPackageManager === "yarn" ? "true" : "",
66
+ })
67
+
68
+ logger.info("installing dependencies using %s...", selectedPackageManager)
69
+
70
+ await installDependencies({
71
+ cwd: destinationPath,
72
+ packageManager: selectedPackageManager,
73
+ silent: false,
74
+ })
75
+
76
+ logger.info("project initialized successfully")
77
+ }
78
+ }
79
+
80
+ async function resolveDestinationPath(pathOption: string | undefined): Promise<string> {
81
+ if (pathOption) {
82
+ return resolve(pathOption)
83
+ }
84
+
85
+ const pathValue = await input({
86
+ message: "Project path",
87
+ default: ".",
88
+ validate: value => (value.trim().length > 0 ? true : "Path is required"),
89
+ })
90
+
91
+ return resolve(pathValue)
92
+ }
93
+
94
+ async function resolveProjectName(
95
+ nameOption: string | undefined,
96
+ defaultName: string,
97
+ ): Promise<string> {
98
+ if (nameOption) {
99
+ return nameOption.trim()
100
+ }
101
+
102
+ const value = await input({
103
+ message: "Project name",
104
+ default: defaultName,
105
+ validate: inputValue => (inputValue.trim().length > 0 ? true : "Name is required"),
106
+ })
107
+
108
+ return value.trim()
109
+ }
110
+
111
+ async function resolvePackageManager(
112
+ packageManagerOption: string | undefined,
113
+ available: PackageManagerName[],
114
+ ): Promise<PackageManagerName> {
115
+ if (packageManagerOption) {
116
+ if (!isSupportedPackageManagerName(packageManagerOption)) {
117
+ throw new Error(`unsupported package manager: ${packageManagerOption}`)
118
+ }
119
+
120
+ const name = packageManagerOption
121
+ if (!available.includes(name)) {
122
+ throw new Error(`package manager not found in PATH: ${name}`)
123
+ }
124
+
125
+ return name
126
+ }
127
+
128
+ const preferredOrder: PackageManagerName[] = ["pnpm", "yarn", "npm"]
129
+ const defaultValue = preferredOrder.find(value => available.includes(value)) ?? available[0]
130
+
131
+ return await select({
132
+ message: "Package manager",
133
+ default: defaultValue,
134
+ choices: available.map(value => ({ name: value, value })),
135
+ })
136
+ }
137
+
138
+ function isSupportedPackageManagerName(value: string): value is PackageManagerName {
139
+ return value === "npm" || value === "pnpm" || value === "yarn"
140
+ }
141
+
142
+ async function detectAvailablePackageManagers(
143
+ candidates: PackageManagerName[],
144
+ ): Promise<PackageManagerName[]> {
145
+ const results: PackageManagerName[] = []
146
+
147
+ for (const candidate of candidates) {
148
+ const exists = await isExecutableInPath(candidate)
149
+ if (exists) {
150
+ results.push(candidate)
151
+ }
152
+ }
153
+
154
+ return results
155
+ }
156
+
157
+ async function isExecutableInPath(command: string): Promise<boolean> {
158
+ const pathValue = process.env.PATH
159
+ if (!pathValue) {
160
+ return false
161
+ }
162
+
163
+ const parts = pathValue.split(":").filter(Boolean)
164
+ for (const part of parts) {
165
+ const candidate = resolve(part, command)
166
+ try {
167
+ await access(candidate)
168
+ return true
169
+ } catch {
170
+ // ignore
171
+ }
172
+ }
173
+
174
+ return false
175
+ }
176
+
177
+ async function isEmptyDirectory(path: string): Promise<boolean> {
178
+ try {
179
+ const entries = await readdir(path)
180
+ return entries.length === 0
181
+ } catch {
182
+ return true
183
+ }
184
+ }
185
+
186
+ function resolveTemplatePath(): string {
187
+ const here = fileURLToPath(new URL(import.meta.url))
188
+ return resolve(here, "..", "..", "assets", "template")
189
+ }
package/src/main.ts CHANGED
@@ -6,6 +6,7 @@ import { BackendUnlockMethodDeleteCommand } from "./commands/backend/unlock-meth
6
6
  import { BackendUnlockMethodListCommand } from "./commands/backend/unlock-method/list"
7
7
  import { BuildCommand } from "./commands/build"
8
8
  import { DesignerCommand } from "./commands/designer"
9
+ import { InitCommand } from "./commands/init"
9
10
  import {
10
11
  CreateCommand as PackageCreateCommand,
11
12
  ListCommand as PackageListCommand,
@@ -21,6 +22,7 @@ const cli = new Cli({
21
22
 
22
23
  cli.register(BuildCommand)
23
24
  cli.register(DesignerCommand)
25
+ cli.register(InitCommand)
24
26
  cli.register(BackendIdentityCommand)
25
27
  cli.register(BackendUnlockMethodListCommand)
26
28
  cli.register(BackendUnlockMethodAddCommand)
@@ -0,0 +1,73 @@
1
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { join, resolve } from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { generateFromTemplate } from "./generator"
6
+
7
+ async function readUtf8(filePath: string): Promise<string> {
8
+ return await readFile(filePath, "utf8")
9
+ }
10
+
11
+ describe("generateFromTemplate", () => {
12
+ it("copies files and replaces handlebars-style variables", async () => {
13
+ const root = await mkdtemp(join(tmpdir(), "highstate-generator-"))
14
+ const templatePath = resolve(root, "template")
15
+ const destinationPath = resolve(root, "dest")
16
+
17
+ await mkdir(templatePath, { recursive: true })
18
+
19
+ await writeFile(resolve(templatePath, "hello.txt"), "Hello, {{name}}!", "utf8")
20
+
21
+ await generateFromTemplate(templatePath, destinationPath, { name: "World" })
22
+
23
+ await expect(readUtf8(resolve(destinationPath, "hello.txt"))).resolves.toBe("Hello, World!")
24
+ })
25
+
26
+ it("removes .tpl from the end of destination filenames", async () => {
27
+ const root = await mkdtemp(join(tmpdir(), "highstate-generator-"))
28
+ const templatePath = resolve(root, "template")
29
+ const destinationPath = resolve(root, "dest")
30
+
31
+ await mkdir(templatePath, { recursive: true })
32
+
33
+ await writeFile(resolve(templatePath, "config.json.tpl"), '{"name":"{{name}}"}', "utf8")
34
+
35
+ await generateFromTemplate(templatePath, destinationPath, { name: "demo" })
36
+
37
+ await expect(readUtf8(resolve(destinationPath, "config.json"))).resolves.toBe('{"name":"demo"}')
38
+ })
39
+
40
+ it("removes .tpl from the middle of destination filenames", async () => {
41
+ const root = await mkdtemp(join(tmpdir(), "highstate-generator-"))
42
+ const templatePath = resolve(root, "template")
43
+ const destinationPath = resolve(root, "dest")
44
+
45
+ await mkdir(templatePath, { recursive: true })
46
+
47
+ await writeFile(
48
+ resolve(templatePath, "package.tpl.json"),
49
+ '{"name":"{{name}}","version":"{{version}}"}',
50
+ "utf8",
51
+ )
52
+
53
+ await generateFromTemplate(templatePath, destinationPath, { name: "demo", version: "1.2.3" })
54
+
55
+ await expect(readUtf8(resolve(destinationPath, "package.json"))).resolves.toBe(
56
+ '{"name":"demo","version":"1.2.3"}',
57
+ )
58
+ })
59
+
60
+ it("does not write files rendered to empty strings", async () => {
61
+ const root = await mkdtemp(join(tmpdir(), "highstate-generator-"))
62
+ const templatePath = resolve(root, "template")
63
+ const destinationPath = resolve(root, "dest")
64
+
65
+ await mkdir(templatePath, { recursive: true })
66
+
67
+ await writeFile(resolve(templatePath, "maybe.txt"), "{{#if enabled}}ok{{/if}}", "utf8")
68
+
69
+ await generateFromTemplate(templatePath, destinationPath, { enabled: "" })
70
+
71
+ await expect(readFile(resolve(destinationPath, "maybe.txt"))).rejects.toThrow(/ENOENT/)
72
+ })
73
+ })
@@ -0,0 +1,76 @@
1
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"
2
+ import { dirname, join, relative, resolve } from "node:path"
3
+ import Handlebars from "handlebars"
4
+
5
+ /**
6
+ * Copies all files from a template path to a destination path, replacing
7
+ * variables in the format {{variableName}} with their corresponding values
8
+ * and removing `.tpl` segments from file names.
9
+ *
10
+ * For example, `package.tpl.json` becomes `package.json` and `config.tpl` becomes `config`.
11
+ *
12
+ * @param templatePath The absolute path to the template directory.
13
+ * @param destinationPath The absolute path to the destination directory.
14
+ * @param variables The record of variable names and their replacement values.
15
+ */
16
+ export async function generateFromTemplate(
17
+ templatePath: string,
18
+ destinationPath: string,
19
+ variables: Record<string, string>,
20
+ ): Promise<void> {
21
+ const resolvedTemplatePath = resolve(templatePath)
22
+ const resolvedDestinationPath = resolve(destinationPath)
23
+
24
+ const templateStats = await stat(resolvedTemplatePath)
25
+ if (!templateStats.isDirectory()) {
26
+ throw new Error(`templatePath must be a directory: ${resolvedTemplatePath}`)
27
+ }
28
+
29
+ await mkdir(resolvedDestinationPath, { recursive: true })
30
+
31
+ const renderTemplate = (raw: string): string => {
32
+ const template = Handlebars.compile(raw, {
33
+ strict: true,
34
+ noEscape: true,
35
+ })
36
+
37
+ return template(variables)
38
+ }
39
+
40
+ const visit = async (absoluteSourcePath: string): Promise<void> => {
41
+ const sourceStats = await stat(absoluteSourcePath)
42
+ if (sourceStats.isDirectory()) {
43
+ const relativePath = relative(resolvedTemplatePath, absoluteSourcePath)
44
+ const destinationDirPath = join(resolvedDestinationPath, relativePath)
45
+
46
+ await mkdir(destinationDirPath, { recursive: true })
47
+
48
+ const entries = await readdir(absoluteSourcePath, { withFileTypes: true })
49
+ for (const entry of entries) {
50
+ await visit(join(absoluteSourcePath, entry.name))
51
+ }
52
+
53
+ return
54
+ }
55
+
56
+ if (!sourceStats.isFile()) {
57
+ return
58
+ }
59
+
60
+ const relativeFilePath = relative(resolvedTemplatePath, absoluteSourcePath)
61
+ const destinationRelativePath = relativeFilePath.replaceAll(".tpl", "")
62
+ const destinationFilePath = join(resolvedDestinationPath, destinationRelativePath)
63
+
64
+ await mkdir(dirname(destinationFilePath), { recursive: true })
65
+
66
+ const contents = await readFile(absoluteSourcePath, "utf8")
67
+ const rendered = renderTemplate(contents)
68
+ if (rendered.trim().length === 0) {
69
+ return
70
+ }
71
+
72
+ await writeFile(destinationFilePath, rendered, "utf8")
73
+ }
74
+
75
+ await visit(resolvedTemplatePath)
76
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./bin-transformer"
2
2
  export * from "./entry-points"
3
+ export * from "./generator"
3
4
  export * from "./logger"
4
5
  export * from "./schema-transformer"
5
6
  export * from "./schemas"