Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
529 changes: 294 additions & 235 deletions package-lock.json

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,31 @@
},
"type": "module",
"dependencies": {
"@aws-sdk/client-lambda": "^3.1102.0",
"@aws-sdk/client-lambda": "^3.1110.0",
"@inquirer/prompts": "^8.5.2",
"@smartthings/core-sdk": "^8.5.3",
"axios": "1.19.0",
"chalk": "^6.0.0",
"env-paths": "^3.0.0",
"eventsource": "^4.1.0",
"env-paths": "^4.0.0",
"eventsource": "^5.1.0",
"express": "^5.2.1",
"get-port-please": "^3.2.0",
"js-yaml": "^4.2.0",
"js-yaml": "^5.3.0",
"jszip": "^3.10.1",
"lodash.at": "^4.6.0",
"log4js": "^6.9.1",
"open": "^11.0.0",
"ora": "^8.2.0",
"open": "^11.0.1",
"ora": "^9.4.1",
"os-locale": "^8.0.0",
"picomatch": "^4.0.5",
"qs": "^6.15.3",
"table": "^6.9.0",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"tsx": "^4.23.5",
"tsx": "^4.23.12",
"undici": "^8.10.0",
"uuid": "^14.0.1",
"yargs": "^18.0.0"
"yargs": "^18.1.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
Expand All @@ -88,13 +88,13 @@
"@types/picomatch": "^4.0.3",
"@types/qs": "^6.15.1",
"@types/yargs": "^17.0.35",
"@vercel/ncc": "^0.44.1",
"@vercel/ncc": "^0.45.0",
"archiver": "^8.0.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^10.8.0",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-jest": "^29.16.0",
"globals": "^17.9.0",
"globals": "^17.11.0",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"jest-extended": "^7.0.0",
Expand Down
30 changes: 15 additions & 15 deletions src/__tests__/lib/cli-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { jest } from '@jest/globals'

import type { readFile, writeFile } from 'node:fs/promises'

import type yaml from 'js-yaml'
import { type load, type dump, YAML11_SCHEMA } from 'js-yaml'

import type {
CLIConfig,
Expand All @@ -20,15 +20,17 @@ jest.unstable_mockModule('node:fs/promises', () => ({
writeFile: writeFileMock,
}))

const yamlLoadMock = jest.fn<typeof yaml.load>()
const yamlDumpMock = jest.fn<typeof yaml.dump>()
const yamlLoadMock = jest.fn<typeof load>()
const yamlDumpMock = jest.fn<typeof dump>()
jest.unstable_mockModule('js-yaml', () => ({
default: {
load: yamlLoadMock,
dump: yamlDumpMock,
},
load: yamlLoadMock,
dump: yamlDumpMock,
// eslint-disable-next-line @typescript-eslint/naming-convention
YAML11_SCHEMA,
}))

const yamlLoadOptions = { schema: YAML11_SCHEMA }

const { loggerMock, warnMock } = await import('../test-lib/logger-mock.js')

const yamlExistsMock = jest.fn<typeof yamlExists>()
Expand Down Expand Up @@ -67,18 +69,16 @@ describe('loadConfigFile', () => {
expect(readFileMock).toHaveBeenCalledTimes(0)
})

it('returns empty object for empty file', async () => {
readFileMock.mockResolvedValueOnce('empty contents')
yamlLoadMock.mockReturnValueOnce('')
it.each([undefined, null, ''])('returns empty object for empty file', async (fileContents) => {
readFileMock.mockResolvedValueOnce(fileContents as string)

expect(await loadConfigFile('empty file')).toEqual({})

expect(yamlExistsMock).toHaveBeenCalledTimes(1)
expect(yamlExistsMock).toHaveBeenCalledWith('empty file')
expect(readFileMock).toHaveBeenCalledTimes(1)
expect(readFileMock).toHaveBeenCalledWith('empty file', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledTimes(1)
expect(yamlLoadMock).toHaveBeenCalledWith('empty contents')
expect(yamlLoadMock).not.toHaveBeenCalled()
})

it.each(['string', ['array']])('throws error for non-object yaml file', async (parsedYAML) => {
Expand All @@ -92,7 +92,7 @@ describe('loadConfigFile', () => {
expect(readFileMock).toHaveBeenCalledTimes(1)
expect(readFileMock).toHaveBeenCalledWith('configFilename.json', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledTimes(1)
expect(yamlLoadMock).toHaveBeenCalledWith('file contents')
expect(yamlLoadMock).toHaveBeenCalledWith('file contents', yamlLoadOptions)
expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(
'invalid config file format\n' + seeConfigDocs,
)
Expand All @@ -113,7 +113,7 @@ describe('loadConfigFile', () => {
expect(readFileMock).toHaveBeenCalledTimes(1)
expect(readFileMock).toHaveBeenCalledWith('file with bad configs', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledTimes(1)
expect(yamlLoadMock).toHaveBeenCalledWith('config with multiple errors')
expect(yamlLoadMock).toHaveBeenCalledWith('config with multiple errors', yamlLoadOptions)
expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(
'bad profile badProfile1; profile must be an object\n' +
'bad profile badProfile2; profile must be an object\n' +
Expand Down Expand Up @@ -141,7 +141,7 @@ describe('loadConfigFile', () => {
expect(readFileMock).toHaveBeenCalledTimes(1)
expect(readFileMock).toHaveBeenCalledWith('good file', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledTimes(1)
expect(yamlLoadMock).toHaveBeenCalledWith('good contents')
expect(yamlLoadMock).toHaveBeenCalledWith('good contents', yamlLoadOptions)
})
})

Expand Down
41 changes: 26 additions & 15 deletions src/__tests__/lib/file-util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { jest } from '@jest/globals'

import { readFileSync, promises as fsPromises, Stats } from 'node:fs'

import type yaml from 'js-yaml'
import { type load, YAML11_SCHEMA } from 'js-yaml'

import { type YAMLFileData } from '../../lib/file-util.js'
import type { fatalError } from '../../lib/util.js'
Expand All @@ -24,13 +24,11 @@ jest.unstable_mockModule('node:fs/promises', () => ({
realpath: realpathMock,
}))

const yamlLoadMock = jest.fn<typeof yaml.load>()
const yamlDumpMock = jest.fn<typeof yaml.dump>()
const yamlLoadMock = jest.fn<typeof load>()
jest.unstable_mockModule('js-yaml', () => ({
default: {
load: yamlLoadMock,
dump: yamlDumpMock,
},
load: yamlLoadMock,
// eslint-disable-next-line @typescript-eslint/naming-convention
YAML11_SCHEMA,
}))

const fatalErrorMock = jest.fn<typeof fatalError>()
Expand Down Expand Up @@ -271,7 +269,7 @@ describe('readYAMLFile', () => {
expect(readYAMLFile('filename')).toBe(yamlFile)

expect(readFileSyncMock).toHaveBeenCalledExactlyOnceWith('filename', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledExactlyOnceWith('file contents')
expect(yamlLoadMock).toHaveBeenCalledExactlyOnceWith('file contents', { schema: YAML11_SCHEMA })
})

it('passes error message into user-facing error', () => {
Expand All @@ -285,18 +283,31 @@ describe('readYAMLFile', () => {
})

it.each`
invalidYaml | errorMessage
${{}} | ${'invalid file filename'}
${null} | ${'empty file filename'}
${''} | ${'invalid file filename'}
${0} | ${'invalid file filename'}
`('throws $errorMessage when reading $invalidYaml', ({ invalidYaml, errorMessage }) => {
invalidYaml
${{}}
${0}
`('throws "invalid file filename" when reading $invalidYaml', ({ invalidYaml }) => {
readFileSyncMock.mockReturnValueOnce('file contents')
yamlLoadMock.mockReturnValueOnce(invalidYaml)
fatalErrorMock.mockReturnValueOnce('never return' as never)

expect(readYAMLFile('filename')).toBe('never return')

expect(fatalErrorMock).toHaveBeenCalledWith(errorMessage)
expect(fatalErrorMock).toHaveBeenCalledWith('invalid file filename')
})

it.each`
invalidYaml
${null}
${''}
${undefined}
`('throws "empty file filename" when reading $invalidYaml', ({ invalidYaml }) => {
readFileSyncMock.mockReturnValueOnce(invalidYaml)
fatalErrorMock.mockReturnValueOnce('never return' as never)

expect(readYAMLFile('filename')).toBe('never return')

expect(fatalErrorMock).toHaveBeenCalledWith('empty file filename')
expect(yamlLoadMock).not.toHaveBeenCalled()
})
})
14 changes: 7 additions & 7 deletions src/__tests__/lib/log-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { jest } from '@jest/globals'
import { type readFileSync } from 'node:fs'

import log4js, { type Logger } from 'log4js'
import type yaml from 'js-yaml'
import { type load, YAML11_SCHEMA } from 'js-yaml'

import type { yamlExists } from '../../lib/io-util.js'
import { fatalError } from '../../lib/util.js'
Expand All @@ -16,11 +16,11 @@ jest.unstable_mockModule('node:fs', () => ({
},
}))

const yamlLoadMock = jest.fn<typeof yaml.load>()
const yamlLoadMock = jest.fn<typeof load>()
jest.unstable_mockModule('js-yaml', () => ({
default: {
load: yamlLoadMock,
},
load: yamlLoadMock,
// eslint-disable-next-line @typescript-eslint/naming-convention
YAML11_SCHEMA,
}))

const yamlExistsMock = jest.fn<typeof yamlExists>()
Expand Down Expand Up @@ -101,7 +101,7 @@ describe('loadLog4jsConfig', () => {
expect(loadLog4jsConfig('filename', defaultConfig)).toStrictEqual(loadedConfig)

expect(readFileSyncMock).toHaveBeenCalledExactlyOnceWith('filename', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledExactlyOnceWith('file contents')
expect(yamlLoadMock).toHaveBeenCalledExactlyOnceWith('file contents', { schema: YAML11_SCHEMA })
})

it('ends with error if config is invalid', () => {
Expand All @@ -115,7 +115,7 @@ describe('loadLog4jsConfig', () => {
expect(loadLog4jsConfig('filename', defaultConfig)).toBe('never return')

expect(readFileSyncMock).toHaveBeenCalledExactlyOnceWith('filename', 'utf-8')
expect(yamlLoadMock).toHaveBeenCalledExactlyOnceWith('bad file contents')
expect(yamlLoadMock).toHaveBeenCalledExactlyOnceWith('bad file contents', { schema: YAML11_SCHEMA })

expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining('invalid or unreadable logging config file format'),
Expand Down
7 changes: 6 additions & 1 deletion src/build-tools/build-binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ const buildAndZipTarget = async (target: string): Promise<void> => {
await compile({
name: 'smartthings',
input: packFile,
resources: ['package.json'],
// nexe's static analysis doesn't always detect the packed entry file as a
// dependency to embed (e.g. it misses it when ncc emits require/import shims
// for ESM-only transitive dependencies), which leaves the compiled binary
// unable to find its own bundled script at runtime. Listing it explicitly
// forces nexe to embed it regardless.
resources: ['package.json', packFile],
remote: 'https://github.com/SmartThingsCommunity/cli-nexe-builds/releases/download/1.0.0',
output: fullBinaryFilename,
python: 'python3',
Expand Down
4 changes: 2 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from 'node:path'

import yaml from 'js-yaml'
import { dump } from 'js-yaml'
import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs'

import { type Profile } from '../lib/cli-config.js'
Expand Down Expand Up @@ -52,7 +52,7 @@ const handler = async (argv: ArgumentsCamelCase<CommandArgs>): Promise<void> =>
]
const tableFieldDefinitions: TableFieldDefinition<ProfileWithName>[] = [
...listTableFieldDefinitions,
{ label: 'Definition', value: item => yaml.dump(item.profile) },
{ label: 'Definition', value: item => dump(item.profile) },
]

const outputListConfig: OutputListConfig<ProfileWithName> = {
Expand Down
9 changes: 5 additions & 4 deletions src/lib/cli-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile, writeFile } from 'node:fs/promises'

import { type Logger } from 'log4js'
import yaml from 'js-yaml'
import { dump, load, YAML11_SCHEMA } from 'js-yaml'

import { yamlExists } from './io-util.js'
import { fatalError } from './util.js'
Expand Down Expand Up @@ -72,7 +72,8 @@ export const loadConfigFile = async (filename: string): Promise<ProfilesByName>
return {}
}

const parsed = yaml.load(await readFile(filename, 'utf-8'))
const contents = await readFile(filename, 'utf-8')
const parsed = contents?.trim() ? load(contents, { schema: YAML11_SCHEMA }) : {}
if (parsed) {
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
const errors: string[] = []
Expand Down Expand Up @@ -178,13 +179,13 @@ export const loadConfig = async (description: CLIConfigDescription, logger: Logg

/**
* Generate managed config file contents. If there are no profiles to save, we skip calling
* `yaml.dump` which returns `{}` in that case.
* `dump` which returns `{}` in that case.
*/
export const buildManagedConfigFileContents = (config: CLIConfig): string =>
`# This file is used to store settings managed by the CLI. Users are not meant to edit it directly.
# Any options in the main config file will override values from this one.

` + (Object.keys(config.managedProfiles).length > 0 ? yaml.dump(config.managedProfiles) : '')
` + (Object.keys(config.managedProfiles).length > 0 ? dump(config.managedProfiles) : '')

/**
* Save the specified configuration key into managed config so it will be picked up
Expand Down
4 changes: 2 additions & 2 deletions src/lib/command/output.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { writeFile } from 'node:fs/promises'

import yaml from 'js-yaml'
import { dump } from 'js-yaml'
import { Argv } from 'yargs'

import { formatFromFilename, IOFormat, stdoutIsTTY } from '../io-util.js'
Expand Down Expand Up @@ -52,7 +52,7 @@ export const jsonFormatter = <T extends object>(indent: number): OutputFormatter
(data: T) => JSON.stringify(data, null, indent)

export const yamlFormatter = <T extends object>(indent: number): OutputFormatter<T> =>
(data: T) => yaml.dump(data, { indent })
(data: T) => dump(data, { indent })

export const itemTableFormatter =
<T extends object>(tableGenerator: TableGenerator, fieldDefinitions: TableFieldDefinition<T>[]): OutputFormatter<T> =>
Expand Down
9 changes: 8 additions & 1 deletion src/lib/command/smartthings-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,14 @@ export const getConfigDirsCheckingForOldConfig = async (
// The documentation says not to use `suffix` "unless you really have to" but the directories
// get suffixed with `-nodejs` without using it. This would be fine for the data directory,
// but for the config and log directories that users might use, it seems rather ugly.
const { config: configDir, data: dataDir, log: logDir } = envPaths('@smartthings/cli', { suffix: '' })
const { config, data, log } = envPaths('@smartthings_cli', { suffix: '' })

// Version 4.0.0 no longer allows `/` as part of the path so we use `_` in the request
// and substitute `/` back in the results.
const backToSlash = (path: string): string => path.replace(/@smartthings_cli/, '@smartthings/cli')
const configDir = backToSlash(config)
const dataDir = backToSlash(data)
const logDir = backToSlash(log)
if (options.verboseLogging) {
console.error(`config dir = ${configDir}`)
console.error(`data dir = ${dataDir}`)
Expand Down
13 changes: 7 additions & 6 deletions src/lib/file-util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'
import { lstat, mkdir, realpath, stat } from 'node:fs/promises'

import yaml from 'js-yaml'
import { load, YAML11_SCHEMA } from 'js-yaml'

import { fatalError } from './util.js'

Expand Down Expand Up @@ -75,13 +75,14 @@ const isYAMLFileData = (data: unknown): data is YAMLFileData =>

export const readYAMLFile = (filename: string): YAMLFileData => {
try {
const data = yaml.load(readFileSync(filename, 'utf-8'))
if (isYAMLFileData(data)) {
return data
const contents = readFileSync(filename, 'utf-8')
if (!contents?.trim()) {
return fatalError(`empty file ${filename}`)
}

if (data == null) {
return fatalError(`empty file ${filename}`)
const data = load(contents, { schema: YAML11_SCHEMA })
if (isYAMLFileData(data)) {
return data
}

return fatalError(`invalid file ${filename}`)
Expand Down
Loading