APIs
@haetae/utils

@haetae/utils

@haetae/utils provides useful utilities for general Heatae workflow.

peerDependencies

Note: This might not be exhaustive and lists only Haetae's packages.

Dependents

Installation

💡

Are you developing a library(e.g. plugin) for Haetae?
It might be more suitable to specify @haetae/utils as peerDependencies than dependencies.

To automatically install @haetae/utils and its peerDependencies

You may want to install @haetae/utils and its peerDependencies all at once.
install-peerdeps (opens in a new tab) is a good tool for that.


# As dependencies
npx install-peerdeps @haetae/utils
# As devDependencies
npx install-peerdeps --dev @haetae/utils

To manually handle installation

You might want to manually deal with the installation.
First, install @haetae/utils itself.


# As dependencies
npm install @haetae/utils
# As devDependencies
npm install --save-dev @haetae/utils

Then, check out peerDependencies and manually handle them.
(e.g. Install them as dependencies or set them as peerDependencies)

# This does not install, but just show peerDependencies.
npm info @haetae/utils peerDependencies

API

pkg

Refer to introduction#pkg.

RecordData

interface RecordData extends Rec {
  '@haetae/utils': {
    files?: Record<string, string>
    pkgVersion: string
  }
}
💡

Record Data Namespace
Record Data can have arbitrary fields. '@haetae/utils' is a namespace to avoid collision. Haetae uses a package name as a namespace by convention.

RecordDataOptions

An argument interface for recordData

interface RecordDataOptions {
  files?: Record<string, string>
  pkgVersion?: string
}

recordData

A function to form Record Data @haetae/utils manages.

Type

(options?: RecordDataOptions) => Promise<RecordData>

Options?

  • files? : filename-hash pairs.
  • pkgVersion? : Version of @haetae/utils. (default: pkg.version.value)

GlobOptions

A function to add a new record under the given command to store.

GlobbyOptions (opens in a new tab), which is part of GlobOptions, is from globby (opens in a new tab).

interface GlobOptions {
  rootDir?: string // A facade option for `globbyOptions.cwd`
  globbyOptions?: GlobbyOptions
}

glob


Path Principles

A function to find files by a glob pattern.
Internally, the task is delegated to globby (opens in a new tab) (v13 as of writing). glob is a facade function (opens in a new tab) for globby, providing a more handy experience by sane default rootDir options and postprocessing. There're many options, but aren't explained here, except roodDir. Refer to globby v13 docs (opens in a new tab) for other options.

Type

(patterns: readonly string[], options?: GlobOptions) => Promise<string[]>

Arguments

  • patterns: Array of glob patterns. (e.g. ['**/*.test.{ts, tsx}'])
  • options? :
    • rootDir? : A directory to start search. Its role is same as globby's cwd option, but make sure to use rootDir, not cwd. (default: core.getConfigDirname())

ExecOptions

An argument interface for exec.

interface ExecOptions {
  uid?: number | undefined
  gid?: number | undefined
  cwd?: string | URL | undefined
  env?: NodeJS.ProcessEnv | undefined
  windowsHide?: boolean | undefined
  timeout?: number | undefined
  shell?: string | undefined
  maxBuffer?: number | undefined
  killSignal?: NodeJS.Signals | number | undefined
  trim?: boolean // An option added from Haetae side. (Not for `childProcess.exec`)
}

exec

A function to execute a script.
Internally, nodejs's childProcess.exec (opens in a new tab) is used.

Type

(command: string, options?: ExecOptions) => Promise<string>

Arguments

  • command : An arbitrary command to execute on shell. This command does NOT mean haetae's command concept.
  • options? : Options for childProcess.exec. Refer to the nodejs official docs (opens in a new tab).
    • trim? : Some commands' result (stdout, stderr) ends with whitespace(s) or line terminator character (e.g. \n). If true, the result would be automatically trimmed (opens in a new tab). If false, the result would be returned as-is. trim is the only option that is not a part of childProcess.exec's original options.

$Exec

Type of $.
It's an interface for function, but simultaneously ExecOptions.

Type

interface $Exec extends ExecOptions {
  (
    statics: TemplateStringsArray,
    ...dynamics: readonly PromiseOr<
      string | number | PromiseOr<string | number>[]
    >[]
  ): Promise<string>
}

$

A wrapper of exec as a Tagged Template (opens in a new tab).
It can have properties as options (ExecOptions) of exec.

Type

$Exec

Usage

You can execute any shell command.

const stdout = await $`echo hello world`
assert(stdout === 'hello world')

Placeholders can be used. Promise is automatically awaited internally.

const stdout = await $`echo ${123} ${'hello'} ${Promise.resolve('world')}`
assert(stdout === '123 hello world')

When a placeholder is an array, a white space (' ') is joined between the elements.

// Array
let stdout = await $`echo ${[Promise.resolve('hello'), 'world']}`
assert(stdout === 'hello world')
 
// Promise<Array>
stdout = await $`echo ${Promise.resolve([
  Promise.resolve('hello'),
  'world',
])}`
assert(stdout === 'hello world')

It can have properties as options (ExecOptions) of exec.
The state of properties of $ does not take effect when independently calling exec.

$.cwd = '/path/to/somewhere'
const stdout = await $`pwd`
assert(stdout === '/path/to/somewhere')

HashOptions

An argument interface for hash.

interface HashOptions {
  algorithm?: 'md5' | 'sha1' | 'sha256' | 'sha512'
  rootDir?: string
  glob?: boolean
}

hash

A function to hash files.
It reads the content of single or multiple files and returns a cryptographic hash string.

💡

Sorted Merkle Tree
When multiple files are given, they are treated as a single depth Merkle Tree (opens in a new tab). However, the files are sorted by their path before hashed, resulting in the same result even when different order is given. For example, hash(['foo.txt', 'bar.txt']) is equal to hash(['bar.txt', 'foo.txt']).

Type

(files: string[], options?: HashOptions) => Promise<string>

Arguments

  • files : Files to hash. (e.g. ['package.json', 'package-lock.json'])
  • options?
    • algorithm? : An hash algorithm to use. (default: 'sha256')
    • rootDir? : A directory to start file search. When an element of files is relative (not absolute), this value is used. Ignored otherwise. (default: core.getConfigDirname())
    • glob? : Whether to enable glob pattern. (default: true)

Usage

env in the config file can be a good place to use hash.

haetae.config.js
import { core, utils, js } from 'haetae'
 
export default core.configure({
  // Other options are omitted for brevity.
  commands: {
    myTest: {
      env: async () => ({
        hash: await utils.hash([
          'jest.config.js',
          'package-lock.json',
        ])
      }),
      run: async () => { /* ... */ }
    },
    myLint: {
      env: async () => ({
        eslintrc: await utils.hash(['.eslintrc.js']),
        eslint: (await js.version('eslint')).major
      }),
      run: async () => { /* ... */ }
    }
  },
})

Usage with glob pattern

If you target many files, consider using glob pattern.

await utils.hash(['foo', 'bar/**/*.json'])

DepsEdge

An interface resolving dependencies edge.
TIP. The prefix Deps stands for 'Dependencies'.

interface DepsEdge {
  dependents: readonly string[]
  dependencies: readonly string[]
}

GraphOptions

An argument interface for graph.

interface GraphOptions {
  edges: readonly DepsEdge[]
  rootDir?: string
  glob?: boolean
}

DepsGraph

A return type of graph.
Its structure is similar to the traditional 'Adjacency List' (opens in a new tab).
TIP. The prefix Deps stands for 'Dependencies'.

interface DepsGraph {
  // key is dependent. Value is Set of dependencies.
  [dependent: string]: Set<string>
}

graph


Path Principles

A function to create a dependency graph.
Unlike js.graph, it's not just for a specific language, but for any dependency graph.

Type

(options?: GraphOptions) => Promise<DepsGraph>

Options?

  • edges : A single or multiple edge(s). The dependents and dependencies have to be file paths, not directories. Each of them supports glob pattern when glob is true.
  • rootDir? : When an element of dependents and dependencies is given as a relative path, rootDir is joined to transform it to an absolute path. (default: core.getConfigDirname())
  • glob? : Whether to enable glob pattern. (default: true)

Basic Usage

Basic usage is guided in Getting Started article.

You can specify any dependency relationship.
This is just a pure function that does not hit the filesystem. Whether the files actually depend on each other does not matter. It only works as you specify.

const result = await graph({
  rootDir: '/path/to',
  edges: [
    {
      dependents: ['src/foo.tsx', 'src/bar.ts'],
      dependencies: ['assets/one.png', 'config/another.json'],
    },
    {
      // 'src/bar.ts' appears again, and it's OK!
      dependents: ['src/bar.ts', 'test/qux.ts'],
      // Absolute path is also OK!
      dependencies: ['/somewhere/the-other.txt'],
    },
  ],
})
 
const expected = {
  '/path/to/src/foo.tsx': new Set([
    '/path/to/assets/one.png',
    '/path/to/config/another.json',
  ]),
  '/path/to/src/bar.ts': new Set([
    '/path/to/assets/one.png',
    '/path/to/config/another.json',
    '/somewhere/the-other.txt',
  ]),
  '/path/to/test/qux.ts': new Set([
    '/somewhere/the-other.txt', // Absolute path is preserved.
  ]),
  '/path/to/assets/one.png': new Set([]),
  '/path/to/config/another.json': new Set([]),
  '/somewhere/the-other.txt': new Set([]),
}
 
assert(deepEqual(result, expected)) // They are same.

mergeGraphs

A function to merge multiple dependency graphs into one single unified graph.

(graphs : DepsGraph[]) => DepsGraph

DepsOptions

An argument interface for deps.

interface DepsOptions {
  entrypoint: string
  graph: DepsGraph
  rootDir?: string
}

deps

A function to get all of the direct and transitive dependencies of a single entry point.
The searched result keeps the order by breath-first approach, without duplication of elements.

(options: DepsOptions) => string[]

Options

  • entrypoint : An entry point to get all of whose direct and transitive dependencies.
  • graph : A dependency graph. Return value of graph is proper.
  • rootDir? : A directory to join with when entrypoint is given as a relative path. (default: core.getConfigDirname())

DependsOnOptions

An argument interface for dependsOn.

💡

DependOnOptions vs DependsOnOptions
There're DependOnOptions (plural) and DependsOnOptions (singular). Don't confuse!

interface DependsOnOptions {
  dependent: string
  dependencies: readonly string[]
  graph: DepsGraph
  rootDir?: string
  glob?: boolean
}

dependsOn

A function to check if a file depends on one of the given files, transitively or directly.

💡

dependOn vs dependsOn
There're dependOn (plural) and dependsOn (singular). Don't confuse!

(options: DependsOnOptions) => Promise<boolean>

Options

  • dependent : A target to check if it is a dependent of at least one of dependencies, directly or transitively.
  • dependencies : Candidates that may be a dependency of Dependents, directly or transitively.
  • graph : A dependency graph. Return value of graph is proper.
  • rootDir? : A directory to join with when dependent or dependencies is given as a relative path. (default: core.getConfigDirname())
  • glob? : Whether to enable glob pattern. (default: true)

Basic Usage

Let's say,

  • a depends on b.
  • c depends on a, which depends on b
  • e does not (even transitively) depend on neither f nor b.
  • f does not (even transitively) depend on b.

then the result would be like this.

const graph = utils.graph({
  edges: [
    {
      dependents: ['a'],
      dependencies: ['b'],
    },
    {
      dependents: ['c'],
      dependencies: ['a'],
    },
    {
      dependents: ['f'],
      dependencies: ['another', 'another2'],
    },
  ],
})
 
await utils.dependsOn({ dependent: 'a', dependencies: ['f', 'b'], graph }) // true
await utils.dependsOn({ dependent: 'c', dependencies: ['f', 'b'], graph }) // true -> transitively
await utils.dependsOn({ dependent: 'f', dependencies: ['f', 'b'], graph }) // true -> 'f' depends on 'f' itself.
await utils.dependsOn({ dependent: 'non-existent', dependencies: ['f', 'b'], graph }) // false -> `graph[dependent] === undefined`, so false
await utils.dependsOn({ dependent: 'a', dependencies: ['non-existent']), graph }) // false
await utils.dependsOn({ dependent: 'c', dependencies: ['non-existent', 'b']), graph }) // true -> at least one (transitive) dependency is found

DependOnOptions

An argument interface for dependOn.

💡

DependOnOptions vs DependsOnOptions
There're DependOnOptions (plural) and DependsOnOptions (singular). Don't confuse!

interface DependOnOptions {
  dependents: readonly string[]
  dependencies: readonly string[]
  graph: DepsGraph
  rootDir?: string
  glob?: boolean
}

dependOn

A function to check if a file depends on one of the given files, transitively or directly.

💡

dependOn vs dependsOn
There're dependOn (plural) and dependsOn (singular). Don't confuse!

(options: DependOnOptions) => Promise<string[]>

Options

  • dependents : Targets to filter by whether it's a dependent of at least one of dependencies, directly or transitively.
  • dependencies : Candidates that may be a dependency of dependent, directly or transitively.
  • graph : A dependency graph. Return value of graph is proper.
  • rootDir? : A directory to join with when dependents, or dependencies are given as relative paths. (default: core.getConfigDirname())
  • glob? : Whether to enable glob pattern. (default: true)

Usage

Basic usage is very similar to js.dependOn, which is guided in the Getting Started article.

ChangedFilesOptions

An argument interface for changedFiles.

interface ChangedFilesOptions {
  rootDir?: string
  renew?: readonly string[]
  hash?: (filename: string) => PromiseOr<string>
  filterByExistence?: boolean
  keepRemovedFiles?: boolean
  reserveRecordData?: boolean
  previousFiles?: Record<string, string>
  glob?: boolean
}

changedFiles


Memoized Path Principles

A function to get a list of changed files by hash comparison.
(Getting Started guide explains its basic usage.)

Type

(files: readonly string[], options?: ChangedFilesOptions) => Promise<string[]>

Arguments

  • files : Files to detect if changed.
  • options?
    • rootDir? : When an element of files is given as a relative path, rootDir is used to calculate the path. (default: core.getConfigDirname().)
    • renew? : A list of files that will be renewed by their current hash. If some elements in the files are missing in renew, they are just subject to compare current and previous hashes to detect if changed. In such cases, the current hashes are not recorded. Rather, previous hashes are recorded by succession. (default: files (the argument))
    • hash? : A function to generate a cryptographic hash for each file. Always an absolute path is given as an argument. (default: (f) => hash([f], { rootDir }))
    • keepRemovedFiles? : Whether to succeed hash of a file that is previously recorded but currently non-existent on the filesystem. (default: true)
    • filterByExistence? : Whether to filter out non-existent files from the result. By default, removed files are treated as changed, so included in the result. But if filterByExistence is true, they aren't included. (default: false)
    • reserveRecordData? : Whether to reserve Record Data. If true, core.reserveRecordData is called internally. If a function, not a boolean, is given, the function is called instead of core.reserveRecordData. (default: true)
    • previousFiles? : File-hash pair dictionary that's in the previous Record Data.
      (default: (await (await getConfig()).store.getRecord<RecordData>())?.data['@haetae/utils'].files)
    • glob? : Whether to enable glob pattern. (default: true)

Usage

Let's say your project is like this.

<your-project>
# Other directories and files like package.json are omitted for brevity
├── haetae.config.js
└── targets
    ├── b
    ├── c
    ├── e
    ├── f
    └── i
haetae.config.js
import { $, core, git, utils, js } from 'haetae'
 
export default core.configure({
  commands: {
    myCommand: {
      run: async () => {
        // ...
        const changedFiles = await utils.changedFiles(['targets/*'])
        console.log('changedFiles:', changedFiles)
        // ...
      },
    },
  },
})

If you run myCommand for the first time, the result would be like this.

terminal
$ haetae myCommand
 
changedFiles: [
  '/path/to/targets/b',
  '/path/to/targets/c',
  '/path/to/targets/e',
  '/path/to/targets/f',
  '/path/to/targets/i'
]
 
✔  success   Command myCommand is successfully executed.
 
⎡ 🕗 time: 2023 May 28 11:06:06 (timestamp: 1685239566483)
⎜ 🌱 env: {}
⎜ 💾 data:
"@haetae/utils":
⎜        files:
⎜          targets/b: d9298e6da7af05e586f751d7970b2c7f24672a8ba6c9ce181dd08d7806d57577
⎜          targets/c: c1da9e80c56455de246bc51f13b08a268cfb18cda6e1cb62aeabe97296be1a96
⎜          targets/e: 68dd4ebaba3b6c6a4de18927efbe62da5ebd1bfd720e2ab73bdb3195773fff9c
⎜          targets/f: d8eb1fc8e0f5d0c6f4a710ee0bfd27eeb43eb3c9d5e57f338715bf5c5a660f36
⎜          targets/i: d7b68040b472acede5847c237f0d5a206caa4f3c4df393ac47ab5f6bd9124a9c
⎣        pkgVersion: 0.0.14

As it's the first time, there're no hashes in the previous Record Data. So b, c, e, f and i are all detected as changed files. And their hashes are recorded in the new Record Data.

If we run it again immediately, no file is detected as changed. The hashes are recorded the same as well.

terminal
$ haetae myCommand
 
changedFiles: []
 
✔  success   Command myCommand is successfully executed.
 
⎡ 🕗 time: 2023 Jun 15 00:40:28 (timestamp: 1686757228698)
⎜ 🌱 env: {}
⎜ 💾 data:
"@haetae/utils":
⎜        files:
⎜          targets/b: d9298e6da7af05e586f751d7970b2c7f24672a8ba6c9ce181dd08d7806d57577
⎜          targets/c: c1da9e80c56455de246bc51f13b08a268cfb18cda6e1cb62aeabe97296be1a96
⎜          targets/e: 68dd4ebaba3b6c6a4de18927efbe62da5ebd1bfd720e2ab73bdb3195773fff9c
⎜          targets/f: d8eb1fc8e0f5d0c6f4a710ee0bfd27eeb43eb3c9d5e57f338715bf5c5a660f36
⎜          targets/i: d7b68040b472acede5847c237f0d5a206caa4f3c4df393ac47ab5f6bd9124a9c
⎣        pkgVersion: 0.0.14

After then, you made some changes on the project.

<your-project>
# Other directories and files like package.json are omitted for brevity
├── haetae.config.js
└── targets
    ├── a
    ├── b
    ├── c
    └── d

a and d are newly created.
e, f, and i are removed.

You modified the config file as well.

haetae.config.js
// Other content is omitted for brevity.
const changedFiles = await utils.changedFiles(['targets/{a,b,c,d,e,f,h}'], {
  renew: ['c', 'd', 'f', 'g']
})

So, the positional argument files is ['targets/{a,b,c,d,e,f,h}'], and the named option renew is ['c', 'd', 'f', 'g'].


changedFiles explanation

What would be the result if we run the command again?

terminal
$ haetae myCommand

It will be partially different relying on options and whether the content is changed.

The rule to determine the result is like this. This example covers every possible scenario.

  • a : Hash is not recorded, as it's not a target to renew. Detected as changed, because it's not in the previous Record Data, but exists on the filesystem currently.
  • b : Previous hash is recorded as it's not a target to renew. Detected as changed if the current and previous hashes are different.
  • c : Current hash is recorded. Detected as changed if the current and previous hashes are different.
  • d : Current hash is recorded. Detected as changed, because it's not in the previous Record Data, but exists on the filesystem currently.
  • e : Previous hash is recorded if options.keepRemovedFiles is true(default). Detected as changed if options.filterByExistence is false (not default).
  • f : Hash is not recorded, as it's a target to renew and doesn't exist on the filesystem currently. Detected as changed if options.filterByExistence is false(not default).
  • g : Hash is not recorded. Not detected as changed.
  • h : Hash is not recorded. Not detected as changed.
  • i : Hash is not recorded. Not detected as changed.