@highstate/cli 0.7.1 → 0.7.3

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.
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@highstate/cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "The CLI for the Highstate project.",
5
5
  "type": "module",
6
6
  "files": [
7
- "dist"
7
+ "assets",
8
+ "dist",
9
+ "src"
8
10
  ],
9
11
  "bin": {
10
12
  "highstate": "dist/main.js"
@@ -13,19 +15,27 @@
13
15
  "access": "public"
14
16
  },
15
17
  "scripts": {
16
- "build": "pkgroll --tsconfig=tsconfig.build.json"
18
+ "build": "pkgroll --clean --tsconfig=tsconfig.build.json"
17
19
  },
18
20
  "dependencies": {
19
- "@highstate/backend": "^0.7.1",
20
- "consola": "^3.4.0"
21
- },
22
- "devDependencies": {
23
21
  "clipanion": "^4.0.0-rc.4",
22
+ "consola": "^3.4.0",
23
+ "crypto-hash": "^3.1.0",
24
24
  "get-port-please": "^3.1.2",
25
+ "import-meta-resolve": "^4.1.0",
25
26
  "nypm": "^0.4.1",
27
+ "oxc-parser": "^0.63.0",
28
+ "oxc-walker": "^0.2.4",
26
29
  "pino": "^9.6.0",
27
30
  "pkg-types": "^1.2.1",
31
+ "remeda": "^2.21.2",
32
+ "tsup": "^8.4.0"
33
+ },
34
+ "devDependencies": {
28
35
  "pkgroll": "^2.5.1"
29
36
  },
30
- "gitHead": "76c38ce5dbf7a710cf0e6339f52e86358537a99a"
37
+ "peerDependencies": {
38
+ "@highstate/backend": "workspace:^0.7.2"
39
+ },
40
+ "gitHead": "5cf7cec27262c8fa1d96f6478833b94841459d64"
31
41
  }
@@ -0,0 +1,94 @@
1
+ import { Command, Option } from "clipanion"
2
+ import { readPackageJSON } from "pkg-types"
3
+ import { mapKeys, mapValues, pipe } from "remeda"
4
+ import { build } from "tsup"
5
+ import { logger, schemaTransformerPlugin, SourceHashCalculator } from "../shared"
6
+
7
+ export class BuildCommand extends Command {
8
+ static paths = [["build"]]
9
+
10
+ static usage = Command.Usage({
11
+ category: "Builder",
12
+ description: "Builds the Highstate library or unit package.",
13
+ })
14
+
15
+ watch = Option.Boolean("--watch", false)
16
+ library = Option.Boolean("--library", false)
17
+
18
+ async execute(): Promise<void> {
19
+ const packageJson = await readPackageJSON()
20
+ const exports = packageJson.exports
21
+
22
+ if (!exports) {
23
+ logger.warn("no exports found in package.json")
24
+ return
25
+ }
26
+
27
+ if (typeof exports !== "object" || Array.isArray(exports)) {
28
+ throw new Error("Exports field in package.json must be an object")
29
+ }
30
+
31
+ const entry = pipe(
32
+ exports,
33
+ mapValues((value, key) => {
34
+ let distPath
35
+
36
+ if (typeof value === "string") {
37
+ distPath = value
38
+ } else if (typeof value === "object" && !Array.isArray(value)) {
39
+ if (!value.default) {
40
+ throw new Error(`Export "${key}" must have a default field in package.json`)
41
+ }
42
+
43
+ if (typeof value.default !== "string") {
44
+ throw new Error(`Export "${key}" default field must be a string in package.json`)
45
+ }
46
+
47
+ distPath = value.default
48
+ } else {
49
+ throw new Error(`Export "${key}" must be a string or an object in package.json`)
50
+ }
51
+
52
+ if (!distPath.startsWith("./dist/")) {
53
+ throw new Error(
54
+ `The default value of export "${key}" must start with "./dist/" in package.json, got "${distPath}"`,
55
+ )
56
+ }
57
+
58
+ if (!distPath.endsWith(".js")) {
59
+ throw new Error(
60
+ `The default value of export "${key}" must end with ".js" in package.json, got "${distPath}"`,
61
+ )
62
+ }
63
+
64
+ const targetName = distPath.slice(7).slice(0, -3)
65
+
66
+ return {
67
+ entryPoint: `./src/${targetName}.ts`,
68
+ targetName,
69
+ distPath,
70
+ }
71
+ }),
72
+ mapKeys((_, value) => value.targetName),
73
+ )
74
+
75
+ await build({
76
+ entry: mapValues(entry, value => value.entryPoint),
77
+ outDir: "dist",
78
+ watch: this.watch,
79
+ sourcemap: true,
80
+ clean: true,
81
+ format: "esm",
82
+ target: "esnext",
83
+ external: ["@pulumi/pulumi"],
84
+ esbuildPlugins: this.library ? [schemaTransformerPlugin] : [],
85
+ })
86
+
87
+ const upToDatePackageJson = await readPackageJSON()
88
+
89
+ const sourceHashCalculator = new SourceHashCalculator(upToDatePackageJson, logger)
90
+ const distPaths = Object.values(entry).map(value => value.distPath)
91
+
92
+ await sourceHashCalculator.writeHighstateManifest("./dist", distPaths)
93
+ }
94
+ }
@@ -0,0 +1,73 @@
1
+ import { Command, UsageError } from "clipanion"
2
+ import { readPackageJSON } from "pkg-types"
3
+ import { addDevDependency } from "nypm"
4
+ import { consola } from "consola"
5
+ import { colorize } from "consola/utils"
6
+ import { getPort } from "get-port-please"
7
+ import { getBackendServices, logger } from "../shared"
8
+
9
+ export class DesignerCommand extends Command {
10
+ static paths = [["designer"]]
11
+
12
+ static usage = Command.Usage({
13
+ category: "Designer",
14
+ description: "Starts the Highstate designer in the current project.",
15
+ })
16
+
17
+ async execute(): Promise<void> {
18
+ const packageJson = await readPackageJSON()
19
+ if (!packageJson.devDependencies?.["@highstate/cli"]) {
20
+ throw new UsageError(
21
+ "This project is not a Highstate project.\n@highstate/cli must be installed as a devDependency.",
22
+ )
23
+ }
24
+
25
+ if (!packageJson.devDependencies?.["@highstate/designer"]) {
26
+ logger.info("Installing @highstate/designer...")
27
+
28
+ await addDevDependency(["@highstate/designer", "classic-level"])
29
+ }
30
+
31
+ logger.info("starting highstate designer...")
32
+
33
+ await getBackendServices()
34
+
35
+ const oldConsoleLog = console.log
36
+
37
+ const port = await getPort()
38
+
39
+ process.env.NITRO_PORT = port.toString()
40
+ process.env.NITRO_HOST = "0.0.0.0"
41
+
42
+ await new Promise<void>(resolve => {
43
+ console.log = (message: string) => {
44
+ if (message.startsWith("Listening on")) {
45
+ resolve()
46
+ }
47
+ }
48
+
49
+ const path = "@highstate/designer/.output/server/index.mjs"
50
+ void import(path)
51
+ })
52
+
53
+ console.log = oldConsoleLog
54
+
55
+ consola.log(
56
+ [
57
+ "\n ",
58
+ colorize("bold", colorize("cyanBright", "Highstate Designer")),
59
+ "\n ",
60
+ colorize("greenBright", "➜ Local: "),
61
+ colorize("underline", colorize("cyanBright", `http://localhost:${port}`)),
62
+ "\n",
63
+ ].join(""),
64
+ )
65
+
66
+ process.on("SIGINT", () => {
67
+ process.stdout.write("\r")
68
+ consola.info("shutting down highstate designer...")
69
+
70
+ setTimeout(() => process.exit(0), 1000)
71
+ })
72
+ }
73
+ }
package/src/main.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Builtins, Cli } from "clipanion"
2
+ import { version } from "../package.json"
3
+ import { DesignerCommand } from "./commands/designer"
4
+ import { BuildCommand } from "./commands/build"
5
+
6
+ const cli = new Cli({
7
+ binaryName: "highstate",
8
+ binaryLabel: "Highstate",
9
+ binaryVersion: version,
10
+ })
11
+
12
+ cli.register(BuildCommand)
13
+ cli.register(DesignerCommand)
14
+ cli.register(Builtins.HelpCommand)
15
+ cli.register(Builtins.VersionCommand)
16
+
17
+ await cli.runExit(process.argv.slice(2))
@@ -0,0 +1,4 @@
1
+ export * from "./services"
2
+ export * from "./logger"
3
+ export * from "./schema-transformer"
4
+ export * from "./source-hash-calculator"
@@ -0,0 +1,54 @@
1
+ import { PassThrough } from "node:stream"
2
+ import pino, { levels } from "pino"
3
+ import { consola, LogLevels } from "consola"
4
+
5
+ export const logger = pino(
6
+ {
7
+ name: "highstate-cli",
8
+ level: process.env.LOG_LEVEL ?? "info",
9
+ },
10
+ createConsolaStream(),
11
+ )
12
+
13
+ consola.level = LogLevels[(process.env.LOG_LEVEL as keyof typeof LogLevels) ?? "info"]
14
+
15
+ function createConsolaStream() {
16
+ const stream = new PassThrough()
17
+
18
+ stream.on("data", data => {
19
+ const { level, msg, error } = JSON.parse(String(data)) as {
20
+ msg: string
21
+ level: number
22
+ error?: unknown
23
+ }
24
+
25
+ const levelLabel = levels.labels[level]
26
+
27
+ switch (levelLabel) {
28
+ case "info":
29
+ consola.info(msg)
30
+ break
31
+ case "warn":
32
+ consola.warn(msg)
33
+ break
34
+ case "error":
35
+ if (error) {
36
+ consola.error(msg, error)
37
+ } else {
38
+ consola.error(msg)
39
+ }
40
+ break
41
+ case "debug":
42
+ consola.debug(msg)
43
+ break
44
+ case "fatal":
45
+ consola.fatal(msg)
46
+ break
47
+ case "trace":
48
+ consola.trace(msg)
49
+ break
50
+ }
51
+ })
52
+
53
+ return stream
54
+ }
@@ -0,0 +1,75 @@
1
+ import type { Plugin } from "esbuild"
2
+ import { readFile } from "node:fs/promises"
3
+ import { parseAsync, type Comment } from "oxc-parser"
4
+ import { walk, type Node } from "oxc-walker"
5
+ import MagicString from "magic-string"
6
+
7
+ export const schemaTransformerPlugin: Plugin = {
8
+ name: "schema-transformer",
9
+ setup(build) {
10
+ build.onLoad({ filter: /src\/.*\.ts$/ }, async args => {
11
+ const content = await readFile(args.path, "utf-8")
12
+
13
+ return {
14
+ contents: await applySchemaTransformations(content),
15
+ loader: "ts",
16
+ }
17
+ })
18
+ },
19
+ }
20
+
21
+ export async function applySchemaTransformations(content: string): Promise<string> {
22
+ const magicString = new MagicString(content)
23
+ const { program, comments } = await parseAsync("file.ts", content)
24
+
25
+ walk(program, {
26
+ enter(node) {
27
+ if (node.type !== "Property" || node.key.type !== "Identifier") {
28
+ return
29
+ }
30
+
31
+ const jsdoc = comments.find(comment => isLeadingComment(content, node, comment))
32
+ if (!jsdoc || !jsdoc.value.includes("@schema")) {
33
+ return
34
+ }
35
+
36
+ magicString.update(
37
+ node.value.start,
38
+ node.value.end,
39
+ `{
40
+ ...${content.substring(node.value.start, node.value.end)},
41
+ description: \`${cleanJsdoc(jsdoc.value)}\`,
42
+ }`,
43
+ )
44
+ },
45
+ })
46
+
47
+ return magicString.toString()
48
+ }
49
+
50
+ function isLeadingComment(content: string, node: Node, comment: Comment) {
51
+ if (comment.end > node.start) {
52
+ return false
53
+ }
54
+
55
+ const contentRange = content.substring(comment.end, node.start)
56
+
57
+ return contentRange.trim().length === 0
58
+ }
59
+
60
+ function cleanJsdoc(str: string) {
61
+ return (
62
+ str
63
+ // remove leading asterisks
64
+ .replace(/^\s*\*/gm, "")
65
+
66
+ // remove @schema tag
67
+ .replace("@schema", "")
68
+
69
+ // escape backticks and dollar signs
70
+ .replace(/\\/g, "\\\\")
71
+ .replace(/`/g, "\\`")
72
+ .replace(/\${/g, "\\${")
73
+ .trim()
74
+ )
75
+ }
@@ -0,0 +1,20 @@
1
+ import type { Services } from "@highstate/backend"
2
+ import { logger } from "./logger"
3
+
4
+ let services: Promise<Services> | undefined
5
+
6
+ export function getBackendServices() {
7
+ if (services) {
8
+ return services
9
+ }
10
+
11
+ services = import("@highstate/backend").then(({ getSharedServices }) => {
12
+ return getSharedServices({
13
+ services: {
14
+ logger: logger.child({}, { msgPrefix: "[backend] " }),
15
+ },
16
+ })
17
+ })
18
+
19
+ return services
20
+ }
@@ -0,0 +1,219 @@
1
+ import type { Logger } from "pino"
2
+ import { dirname, relative, resolve } from "node:path"
3
+ import { readFile, writeFile } from "node:fs/promises"
4
+ import { fileURLToPath } from "node:url"
5
+ import { readPackageJSON, resolvePackageJSON, type PackageJson } from "pkg-types"
6
+ import { sha256 } from "crypto-hash"
7
+ import { resolve as importMetaResolve } from "import-meta-resolve"
8
+
9
+ export type HighstateManifestJson = {
10
+ sourceHashes?: Record<string, string>
11
+ }
12
+
13
+ type FileDependency =
14
+ | {
15
+ type: "relative"
16
+ id: string
17
+ fullPath: string
18
+ }
19
+ | {
20
+ type: "npm"
21
+ id: string
22
+ package: string
23
+ }
24
+
25
+ export class SourceHashCalculator {
26
+ private readonly dependencyHashes = new Map<string, Promise<string>>()
27
+ private readonly fileHashes = new Map<string, Promise<string>>()
28
+
29
+ constructor(
30
+ private readonly packageJson: PackageJson,
31
+ private readonly logger: Logger,
32
+ ) {}
33
+
34
+ async writeHighstateManifest(distBasePath: string, distPaths: string[]): Promise<void> {
35
+ const promises: Promise<{ distPath: string; hash: string }>[] = []
36
+
37
+ for (const distPath of distPaths) {
38
+ const fullPath = resolve(distPath)
39
+
40
+ promises.push(
41
+ this.getFileHash(fullPath).then(hash => ({
42
+ distPath,
43
+ hash,
44
+ })),
45
+ )
46
+ }
47
+
48
+ const manifest: HighstateManifestJson = {
49
+ sourceHashes: {},
50
+ }
51
+
52
+ const hashes = await Promise.all(promises)
53
+ for (const { distPath, hash } of hashes) {
54
+ manifest.sourceHashes![distPath] = hash
55
+ }
56
+
57
+ const manifestPath = resolve(distBasePath, "highstate.manifest.json")
58
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8")
59
+ }
60
+
61
+ private async getFileHash(fullPath: string): Promise<string> {
62
+ const existingHash = this.fileHashes.get(fullPath)
63
+ if (existingHash) {
64
+ return existingHash
65
+ }
66
+
67
+ const hash = this.calculateFileHash(fullPath)
68
+ this.fileHashes.set(fullPath, hash)
69
+
70
+ return hash
71
+ }
72
+
73
+ private async calculateFileHash(fullPath: string): Promise<string> {
74
+ const content = await readFile(fullPath, "utf8")
75
+ const fileDeps = this.parseDependencies(fullPath, content)
76
+
77
+ const hashes = await Promise.all([
78
+ sha256(content),
79
+ ...fileDeps.map(dep => this.getDependencyHash(dep)),
80
+ ])
81
+
82
+ return await sha256(hashes.join(""))
83
+ }
84
+
85
+ getDependencyHash(dependency: FileDependency): Promise<string> {
86
+ const existingHash = this.dependencyHashes.get(dependency.id)
87
+ if (existingHash) {
88
+ return existingHash
89
+ }
90
+
91
+ const hash = this.calculateDependencyHash(dependency)
92
+ this.dependencyHashes.set(dependency.id, hash)
93
+
94
+ return hash
95
+ }
96
+
97
+ private async calculateDependencyHash(dependency: FileDependency): Promise<string> {
98
+ switch (dependency.type) {
99
+ case "relative": {
100
+ return await this.getFileHash(dependency.fullPath)
101
+ }
102
+ case "npm": {
103
+ let resolvedUrl
104
+ try {
105
+ resolvedUrl = importMetaResolve(dependency.package, import.meta.url)
106
+ } catch (error) {
107
+ this.logger.error(`failed to resolve package "%s"`, dependency.package)
108
+ throw error
109
+ }
110
+
111
+ if (resolvedUrl.startsWith("node:")) {
112
+ throw new Error(`"${dependency.package}" imported without "node:" prefix`)
113
+ }
114
+
115
+ const resolvedPath = fileURLToPath(resolvedUrl)
116
+
117
+ const [depPackageJsonPath, depPackageJson] = await this.getPackageJson(resolvedPath)
118
+ const packageName = depPackageJson.name!
119
+
120
+ this.logger.debug(
121
+ `resolved package.json for "%s": "%s"`,
122
+ dependency.package,
123
+ depPackageJsonPath,
124
+ )
125
+
126
+ if (
127
+ !this.packageJson.dependencies?.[packageName] &&
128
+ !this.packageJson.peerDependencies?.[packageName]
129
+ ) {
130
+ this.logger.warn(`package "%s" is not listed in package.json dependencies`, packageName)
131
+ }
132
+
133
+ let relativePath = relative(dirname(depPackageJsonPath), resolvedPath)
134
+ relativePath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`
135
+
136
+ const highstateManifestPath = resolve(
137
+ dirname(depPackageJsonPath),
138
+ "dist",
139
+ "highstate.manifest.json",
140
+ )
141
+
142
+ let manifest: HighstateManifestJson | undefined
143
+ try {
144
+ const manifestContent = await readFile(highstateManifestPath, "utf8")
145
+ manifest = JSON.parse(manifestContent) as HighstateManifestJson
146
+ } catch (error) {
147
+ this.logger.debug(
148
+ { error },
149
+ `failed to read highstate manifest for package "%s"`,
150
+ packageName,
151
+ )
152
+ }
153
+
154
+ const sourceHash = manifest?.sourceHashes?.[relativePath]
155
+
156
+ if (sourceHash) {
157
+ this.logger.debug(`resolved source hash for package "%s"`, packageName)
158
+ return sourceHash
159
+ }
160
+
161
+ // use the package version as a fallback hash
162
+ // this case will be applied for most npm packages
163
+ this.logger.debug(`using package version as a fallback hash for "%s"`, packageName)
164
+ return depPackageJson.version ?? "0.0.0"
165
+ }
166
+ }
167
+ }
168
+
169
+ private async getPackageJson(basePath: string): Promise<[string, PackageJson]> {
170
+ while (true) {
171
+ const packageJson = await readPackageJSON(basePath)
172
+ if (packageJson.name) {
173
+ const packageJsonPath = await resolvePackageJSON(basePath)
174
+
175
+ return [packageJsonPath, packageJson]
176
+ }
177
+
178
+ basePath = resolve(dirname(basePath), "..")
179
+ }
180
+ }
181
+
182
+ private parseDependencies(filePath: string, content: string): FileDependency[] {
183
+ type DependencyMatch = {
184
+ relativePath?: string
185
+ nodeBuiltin?: string
186
+ npmPackage?: string
187
+ }
188
+
189
+ const dependencyRegex =
190
+ /^[ \t]*import[\s\S]*?\bfrom\s*["']((?<relativePath>\.\.?\/[^"']+)|(?<nodeBuiltin>node:[^"']+)|(?<npmPackage>[^"']+))["']/gm
191
+
192
+ const matches = content.matchAll(dependencyRegex)
193
+ const dependencies: FileDependency[] = []
194
+
195
+ for (const match of matches) {
196
+ const { nodeBuiltin, npmPackage, relativePath } = match.groups as DependencyMatch
197
+
198
+ if (relativePath) {
199
+ const fullPath = resolve(dirname(filePath), relativePath)
200
+
201
+ dependencies.push({
202
+ type: "relative",
203
+ id: `relative:${fullPath}`,
204
+ fullPath,
205
+ })
206
+ } else if (npmPackage) {
207
+ dependencies.push({
208
+ type: "npm",
209
+ id: `npm:${npmPackage}`,
210
+ package: npmPackage,
211
+ })
212
+ } else if (nodeBuiltin) {
213
+ // ignore node built-in modules
214
+ }
215
+ }
216
+
217
+ return dependencies
218
+ }
219
+ }