@nosto/nosto-cli 1.0.4 → 1.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.
Files changed (45) hide show
  1. package/.github/copilot-instructions.md +2 -2
  2. package/.github/dependabot.yml +12 -1
  3. package/README.md +2 -2
  4. package/eslint.config.js +11 -1
  5. package/package.json +8 -8
  6. package/src/api/retry.ts +24 -8
  7. package/src/config/authConfig.ts +2 -2
  8. package/src/config/config.ts +7 -1
  9. package/src/errors/InvalidLoginResponseError.ts +1 -1
  10. package/src/errors/withErrorHandler.ts +10 -14
  11. package/src/filesystem/homeDirectory.ts +3 -0
  12. package/src/filesystem/processInBatches.ts +1 -0
  13. package/src/modules/login.ts +10 -4
  14. package/src/modules/search-templates/push.ts +1 -21
  15. package/src/modules/setup.ts +1 -3
  16. package/src/modules/status.ts +1 -1
  17. package/src/vite-env.d.ts +1 -0
  18. package/test/api/retry.test.ts +41 -1
  19. package/test/commander.test.ts +31 -2
  20. package/test/config/authConfig.test.ts +62 -0
  21. package/test/config/config.test.ts +71 -0
  22. package/test/console/logger.test.ts +16 -0
  23. package/test/console/userPrompt.test.ts +30 -0
  24. package/test/errors/withErrorHandler.test.ts +80 -5
  25. package/test/filesystem/asserts/assertGitRepo.test.ts +35 -0
  26. package/test/filesystem/asserts/assertNostoTemplate.test.ts +53 -0
  27. package/test/filesystem/filesystem.test.ts +17 -1
  28. package/test/filesystem/plugins.test.ts +73 -2
  29. package/test/filesystem/processInBatches.test.ts +34 -0
  30. package/test/modules/login.test.ts +38 -0
  31. package/test/modules/logout.test.ts +24 -0
  32. package/test/modules/search-templates/build.legacy.test.ts +25 -0
  33. package/test/modules/search-templates/build.modern.test.ts +5 -1
  34. package/test/modules/search-templates/dev.legacy.test.ts +23 -0
  35. package/test/modules/search-templates/pull.test.ts +38 -1
  36. package/test/modules/search-templates/push.test.ts +145 -1
  37. package/test/modules/setup.test.ts +16 -0
  38. package/test/modules/status.test.ts +45 -4
  39. package/test/setup.ts +11 -0
  40. package/test/utils/generateEndpointMock.ts +10 -3
  41. package/test/utils/mockAuthServer.ts +72 -0
  42. package/test/utils/mockConsole.ts +20 -0
  43. package/test/utils/mockFileSystem.ts +46 -12
  44. package/test/utils/mockStarterManifest.ts +3 -2
  45. package/vitest.config.ts +13 -5
@@ -74,7 +74,7 @@ tsx /path/to/nosto-cli/src/index.ts st --help
74
74
  tsx /path/to/nosto-cli/src/index.ts st build --help
75
75
 
76
76
  # Test dry run (will attempt API connection and fail - this is expected)
77
- mkdir -p src && echo 'console.log("test");' > src/test.js
77
+ mkdir -p src && echo 'console.info("test");' > src/test.js
78
78
  tsx /path/to/nosto-cli/src/index.ts st build --dry-run
79
79
  ```
80
80
 
@@ -220,7 +220,7 @@ const server = setupMockServer()
220
220
  // Usage
221
221
  mockFetchSourceFile(server, {
222
222
  path: "index.js",
223
- response: "console.log('test')"
223
+ response: "console.info('test')"
224
224
  })
225
225
  ```
226
226
 
@@ -6,4 +6,15 @@ updates:
6
6
  interval: "weekly"
7
7
  commit-message:
8
8
  prefix: "deps"
9
- open-pull-requests-limit: 10
9
+ open-pull-requests-limit: 10
10
+ groups:
11
+ eslint:
12
+ patterns:
13
+ - "@eslint/*"
14
+ - "eslint"
15
+ - "eslint-*"
16
+ - "typescript-eslint"
17
+ vitest:
18
+ patterns:
19
+ - "@vitest/*"
20
+ - "vitest"
package/README.md CHANGED
@@ -6,7 +6,7 @@ A command-line interface to interact with Nosto's backend systems. Primarily aim
6
6
 
7
7
  # About Nosto
8
8
 
9
- If you are unfamiliar with Nosto as a company, you are welcome to visit our homepage at [https://nosto.com/](https://www.nosto.com/).
9
+ If you are unfamiliar with Nosto as a company, you are welcome to visit our homepage at [https://www.nosto.com](https://www.nosto.com/).
10
10
 
11
11
  If you wish to know more about our tech stack, we publish extensive documentation known as the [Techdocs](https://docs.nosto.com/techdocs).
12
12
 
@@ -155,4 +155,4 @@ Running `nosto st push` after `nosto st dev` without changing any files will sto
155
155
 
156
156
  ## Contributions
157
157
 
158
- We welcome both internal and external contributions to this project! Feel free to request bugfixes, open issues or PRs for review. Nosto is actively maintaining this project, and we will process your contributions as soon as possible.
158
+ We welcome both internal and external contributions to this project! Feel free to request bugfixes, open issues or PRs for review. Nosto is actively maintaining this project, and we will process your contributions as soon as possible.
package/eslint.config.js CHANGED
@@ -27,9 +27,19 @@ export default defineConfig([
27
27
  },
28
28
  rules: {
29
29
  "unused-imports/no-unused-imports": "error",
30
+ "unused-imports/no-unused-vars": [
31
+ "warn",
32
+ {
33
+ vars: "all",
34
+ varsIgnorePattern: "^_",
35
+ args: "after-used",
36
+ argsIgnorePattern: "^_"
37
+ }
38
+ ],
30
39
  "simple-import-sort/imports": "error",
31
40
  "simple-import-sort/exports": "error",
32
- "no-restricted-imports": ["error", "node:test"]
41
+ "no-restricted-imports": ["error", "node:test"],
42
+ "@typescript-eslint/no-unused-vars": "off"
33
43
  }
34
44
  },
35
45
  eslintPluginPrettierRecommended
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosto/nosto-cli",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "main": "./src/index.ts",
5
5
  "bin": {
6
6
  "nosto": "./src/bootstrap.mjs"
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "chalk": "^5.6.2",
33
33
  "commander": "^14.0.1",
34
- "esbuild": "^0.25.10",
34
+ "esbuild": "^0.27.0",
35
35
  "ignore": "^7.0.5",
36
36
  "ky": "^1.11.0",
37
37
  "open": "^10.2.0",
@@ -43,9 +43,9 @@
43
43
  "@commitlint/cli": "^20.1.0",
44
44
  "@commitlint/config-conventional": "^20.0.0",
45
45
  "@eslint/js": "^9.37.0",
46
- "@types/node": "^24.7.0",
47
- "@vitest/coverage-v8": "^3.2.4",
48
- "@vitest/ui": "^3.2.4",
46
+ "@types/node": "^24.7.2",
47
+ "@vitest/coverage-v8": "^4.0.6",
48
+ "@vitest/ui": "^4.0.6",
49
49
  "eslint": "^9.37.0",
50
50
  "eslint-config-prettier": "^10.1.8",
51
51
  "eslint-plugin-prettier": "^5.5.4",
@@ -53,10 +53,10 @@
53
53
  "eslint-plugin-unused-imports": "^4.2.0",
54
54
  "husky": "^9.1.7",
55
55
  "memfs": "^4.48.1",
56
- "msw": "^2.11.3",
56
+ "msw": "^2.11.5",
57
57
  "prettier": "^3.6.2",
58
- "typescript-eslint": "^8.45.0",
59
- "vitest": "^3.2.4"
58
+ "typescript-eslint": "^8.46.0",
59
+ "vitest": "^4.0.6"
60
60
  },
61
61
  "publishConfig": {
62
62
  "access": "public"
package/src/api/retry.ts CHANGED
@@ -2,27 +2,43 @@ import chalk from "chalk"
2
2
 
3
3
  import { Logger } from "#console/logger.ts"
4
4
 
5
+ import { putSourceFile } from "./source/putSourceFile.ts"
6
+
5
7
  const MAX_RETRIES = 3
6
8
  const INITIAL_RETRY_DELAY = 1000 // 1 second
7
9
 
8
- export async function fetchWithRetry(
9
- apiFunction: (filePath: string) => Promise<string>,
10
+ async function executeWithRetry<T>(
11
+ operation: () => Promise<T>,
10
12
  filePath: string,
13
+ operationType: "fetch" | "push",
11
14
  retryCount = 0
12
- ): Promise<string> {
15
+ ): Promise<T> {
13
16
  try {
14
- return await apiFunction(filePath)
17
+ return await operation()
15
18
  } catch (error: unknown) {
16
19
  if (retryCount >= MAX_RETRIES) {
17
20
  const errorMessage = error instanceof Error ? error.message : String(error)
18
- Logger.error(`${chalk.red("")} ${chalk.cyan(filePath)}: ${errorMessage}`)
19
- throw new Error(`Failed to fetch ${filePath} after ${MAX_RETRIES} retries: ${errorMessage}`)
21
+ if (operationType === "fetch") {
22
+ Logger.error(`${chalk.red("✗")} ${chalk.cyan(filePath)}: ${errorMessage}`)
23
+ }
24
+ throw new Error(`Failed to ${operationType} ${filePath} after ${MAX_RETRIES} retries: ${errorMessage}`)
20
25
  }
21
26
  const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount)
22
27
  Logger.warn(
23
- `${chalk.yellow("⟳")} Failed to fetch ${chalk.cyan(filePath)}: Retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`
28
+ `${chalk.yellow("⟳")} Failed to ${operationType} ${chalk.cyan(filePath)}: Retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`
24
29
  )
25
30
  await new Promise(resolve => setTimeout(resolve, delay))
26
- return fetchWithRetry(apiFunction, filePath, retryCount + 1)
31
+ return executeWithRetry(operation, filePath, operationType, retryCount + 1)
27
32
  }
28
33
  }
34
+
35
+ export async function fetchWithRetry(
36
+ apiFunction: (filePath: string) => Promise<string>,
37
+ filePath: string
38
+ ): Promise<string> {
39
+ return executeWithRetry(() => apiFunction(filePath), filePath, "fetch")
40
+ }
41
+
42
+ export async function putWithRetry(filePath: string, content: string): Promise<void> {
43
+ return executeWithRetry(() => putSourceFile(filePath, content), filePath, "push")
44
+ }
@@ -1,13 +1,13 @@
1
1
  import fs from "fs"
2
- import os from "os"
3
2
  import path from "path"
4
3
  import z from "zod"
5
4
 
6
5
  import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
6
+ import { HomeDirectory } from "#filesystem/homeDirectory.ts"
7
7
 
8
8
  import { type AuthConfig, AuthConfigSchema } from "./schema.ts"
9
9
 
10
- export const AuthConfigFilePath = path.join(os.homedir(), ".nosto", ".auth.json")
10
+ export const AuthConfigFilePath = path.join(HomeDirectory, ".nosto", ".auth.json")
11
11
 
12
12
  export function authFileExists() {
13
13
  return fs.existsSync(AuthConfigFilePath)
@@ -17,7 +17,7 @@ import {
17
17
  import { parseSearchTemplatesConfigFile } from "./searchTemplatesConfig.ts"
18
18
 
19
19
  let isConfigLoaded = false
20
- let cachedConfig: Config = {
20
+ const defaultCachedConfig: Config = {
21
21
  ...getDefaultConfig(),
22
22
  auth: AuthConfigSchema.parse({
23
23
  user: "",
@@ -30,6 +30,7 @@ let cachedConfig: Config = {
30
30
  },
31
31
  ...RuntimeConfigSchema.parse({})
32
32
  }
33
+ let cachedConfig = defaultCachedConfig
33
34
 
34
35
  export type LoadConfigProps = {
35
36
  projectPath: string
@@ -107,3 +108,8 @@ export function getDefaultConfig() {
107
108
  merchant: ""
108
109
  })
109
110
  }
111
+
112
+ export function clearCachedConfig() {
113
+ isConfigLoaded = false
114
+ cachedConfig = defaultCachedConfig
115
+ }
@@ -9,6 +9,6 @@ export class InvalidLoginResponseError extends NostoError {
9
9
  }
10
10
 
11
11
  handle() {
12
- Logger.error(this.message)
12
+ Logger.error(`Received malformed login response from server. This is probably a bug on our side.`, this)
13
13
  }
14
14
  }
@@ -4,34 +4,30 @@ import { HTTPError, TimeoutError } from "ky"
4
4
  import { getCachedConfig } from "#config/config.ts"
5
5
  import { Logger } from "#console/logger.ts"
6
6
 
7
- import { InvalidLoginResponseError } from "./InvalidLoginResponseError.ts"
8
7
  import { NostoError } from "./NostoError.ts"
9
8
 
10
9
  export async function withErrorHandler(fn: () => void | Promise<void>): Promise<void> {
10
+ function printStack(error: Error) {
11
+ const config = getCachedConfig()
12
+ if (config.verbose) {
13
+ Logger.raw(chalk.red(prettyPrintStack(error.stack)))
14
+ } else {
15
+ Logger.info(chalk.gray("Rerun with --verbose to see details"))
16
+ }
17
+ }
11
18
  try {
12
19
  await fn()
13
20
  } catch (error) {
14
21
  if (error instanceof HTTPError) {
15
- const config = getCachedConfig()
16
22
  Logger.error(`HTTP Request failed:`)
17
23
  Logger.error(`- ${error.response.status} ${error.response.statusText}`)
18
24
  Logger.error(`- ${error.request.method} ${error.request.url}`)
19
- if (config.verbose) {
20
- Logger.raw(chalk.red(prettyPrintStack(error.stack)))
21
- }
22
- if (!config.verbose) {
23
- Logger.info(chalk.gray("Rerun with --verbose to see details"))
24
- }
25
+ printStack(error)
25
26
  } else if (error instanceof TimeoutError) {
26
- const config = getCachedConfig()
27
27
  Logger.error(`HTTP Request timed out:`)
28
28
  Logger.error(`- Server did not respond after 10 seconds`)
29
29
  Logger.error(`- ${error.request.method} ${error.request.url}`)
30
- if (config.verbose) {
31
- Logger.raw(chalk.red(prettyPrintStack(error.stack)))
32
- }
33
- } else if (error instanceof InvalidLoginResponseError) {
34
- Logger.error(`Received malformed login response from server. This is probably a bug on our side.`, error)
30
+ printStack(error)
35
31
  } else if (error instanceof NostoError) {
36
32
  error.handle()
37
33
  } else {
@@ -0,0 +1,3 @@
1
+ import os from "os"
2
+
3
+ export const HomeDirectory = import.meta.env.MODE === "test" ? "/vitest/home" : os.homedir()
@@ -27,6 +27,7 @@ export async function processInBatches({ files, logIcon, processElement }: Props
27
27
  } catch (error: unknown) {
28
28
  const errorMessage = error instanceof Error ? error.message : String(error)
29
29
  Logger.error(`${chalk.red("✗")} ${chalk.cyan(file)}: ${errorMessage}`)
30
+ throw error
30
31
  }
31
32
  })
32
33
  const results = await Promise.allSettled(batchPromises)
@@ -29,6 +29,9 @@ export async function loginToPlaycart() {
29
29
 
30
30
  Logger.info("Awaiting response from the browser...")
31
31
  const response = await server.responseData
32
+ if ("error" in response) {
33
+ throw new InvalidLoginResponseError(response.error)
34
+ }
32
35
  writeFile(AuthConfigFilePath, JSON.stringify(response, null, 2) + "\n")
33
36
 
34
37
  Logger.success(`Login successful! Auth file saved at ${AuthConfigFilePath}`)
@@ -39,7 +42,7 @@ type AuthServer = {
39
42
  responseData: Promise<PlaycartResponse>
40
43
  }
41
44
 
42
- type PlaycartResponse = z.infer<typeof AuthConfigSchema>
45
+ type PlaycartResponse = z.infer<typeof AuthConfigSchema> | { error: string }
43
46
 
44
47
  /**
45
48
  * The authentication server is created to handle a single redirect from the browser.
@@ -58,7 +61,9 @@ async function createAuthServer(): Promise<AuthServer> {
58
61
  expiresAt: url.searchParams.get("expiresAt")
59
62
  })
60
63
  if (!parsed.success) {
61
- throw new InvalidLoginResponseError(`Failed to parse playcart response: ${parsed.error.message}`)
64
+ const error = `Failed to parse playcart response: ${parsed.error.message}`
65
+ resolveTokenPromise({ error })
66
+ throw new InvalidLoginResponseError(error)
62
67
  }
63
68
  resolveTokenPromise(parsed.data)
64
69
  }
@@ -70,11 +75,12 @@ async function createAuthServer(): Promise<AuthServer> {
70
75
  server.close()
71
76
  })
72
77
 
73
- const port = await new Promise<number>(resolve => {
78
+ const port = await new Promise<number>((resolve, reject) => {
74
79
  server.listen(0, "localhost", () => {
75
80
  const addr = server.address()
76
81
  if (!addr || typeof addr !== "object") {
77
- throw new Error("Failed to get server address")
82
+ reject(new Error("Failed to get server address"))
83
+ return
78
84
  }
79
85
  resolve(addr.port)
80
86
  })
@@ -2,8 +2,8 @@ import chalk from "chalk"
2
2
  import fs from "fs"
3
3
  import path from "path"
4
4
 
5
+ import { putWithRetry } from "#api/retry.ts"
5
6
  import { fetchSourceFileIfExists } from "#api/source/fetchSourceFile.ts"
6
- import { putSourceFile } from "#api/source/putSourceFile.ts"
7
7
  import { getCachedConfig } from "#config/config.ts"
8
8
  import { Logger } from "#console/logger.ts"
9
9
  import { promptForConfirmation } from "#console/userPrompt.ts"
@@ -11,9 +11,6 @@ import { calculateTreeHash } from "#filesystem/calculateTreeHash.ts"
11
11
  import { listAllFiles, readFileIfExists, writeFile } from "#filesystem/filesystem.ts"
12
12
  import { processInBatches } from "#filesystem/processInBatches.ts"
13
13
 
14
- const MAX_RETRIES = 3
15
- const INITIAL_RETRY_DELAY = 1000 // 1 second
16
-
17
14
  type PushSearchTemplateOptions = {
18
15
  // Filter to only push these files. Ignored if empty.
19
16
  paths: string[]
@@ -102,20 +99,3 @@ export async function pushSearchTemplate({ paths, force }: PushSearchTemplateOpt
102
99
  }
103
100
  })
104
101
  }
105
-
106
- async function putWithRetry(filePath: string, content: string, retryCount = 0): Promise<void> {
107
- try {
108
- return await putSourceFile(filePath, content)
109
- } catch (error: unknown) {
110
- if (retryCount >= MAX_RETRIES) {
111
- const errorMessage = error instanceof Error ? error.message : String(error)
112
- throw new Error(`Failed to push ${filePath} after ${MAX_RETRIES} retries: ${errorMessage}`)
113
- }
114
- const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount)
115
- Logger.warn(
116
- `${chalk.yellow("⟳")} Failed to push ${chalk.cyan(filePath)}: Retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`
117
- )
118
- await new Promise(resolve => setTimeout(resolve, delay))
119
- return putWithRetry(filePath, content, retryCount + 1)
120
- }
121
- }
@@ -68,9 +68,7 @@ export async function printSetupHelp(projectPath: string, options: Options) {
68
68
  const envConfig = getEnvConfig()
69
69
  const configToCreate = defaultConfig
70
70
  Object.entries(envConfig).forEach(([key, value]) => {
71
- if (key in configToCreate) {
72
- Object.assign(configToCreate, { [key]: value })
73
- }
71
+ Object.assign(configToCreate, { [key]: value })
74
72
  })
75
73
 
76
74
  const { merchant } = options
@@ -9,7 +9,7 @@ export async function printStatus(projectPath: string) {
9
9
  await loadConfig({ projectPath, options: {} })
10
10
  } catch (error) {
11
11
  if (error instanceof MissingConfigurationError) {
12
- Logger.error("Some required configuration is missing\n")
12
+ // Configuration is missing, we will report it below
13
13
  } else {
14
14
  throw error
15
15
  }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
2
 
3
- import { fetchWithRetry } from "#api/retry.ts"
3
+ import { fetchWithRetry, putWithRetry } from "#api/retry.ts"
4
+ import * as putSourceFileModule from "#api/source/putSourceFile.ts"
4
5
  import { setupMockConsole } from "#test/utils/mockConsole.ts"
5
6
 
6
7
  const terminal = setupMockConsole()
@@ -99,4 +100,43 @@ describe("API Retry", () => {
99
100
  await assertion
100
101
  })
101
102
  })
103
+
104
+ describe("putWithRetry", () => {
105
+ let putSourceFileSpy: ReturnType<typeof vi.spyOn>
106
+
107
+ beforeEach(() => {
108
+ vi.clearAllMocks()
109
+ putSourceFileSpy = vi.spyOn(putSourceFileModule, "putSourceFile")
110
+ })
111
+
112
+ afterEach(() => {
113
+ putSourceFileSpy.mockRestore()
114
+ })
115
+
116
+ it("should complete successfully on first attempt", async () => {
117
+ putSourceFileSpy.mockResolvedValue(undefined)
118
+
119
+ await putWithRetry("test-file.txt", "content")
120
+
121
+ expect(putSourceFileSpy).toHaveBeenCalledTimes(1)
122
+ expect(putSourceFileSpy).toHaveBeenCalledWith("test-file.txt", "content")
123
+ })
124
+
125
+ it("should use 'push' operation type in error messages", async () => {
126
+ putSourceFileSpy.mockRejectedValue(new Error("Network error"))
127
+
128
+ const retryPromise = putWithRetry("test-file.txt", "content")
129
+
130
+ const assertion = expect(retryPromise).rejects.toThrow(
131
+ "Failed to push test-file.txt after 3 retries: Network error"
132
+ )
133
+ await vi.runAllTimersAsync()
134
+
135
+ await assertion
136
+
137
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith(
138
+ expect.stringContaining("Failed to push test-file.txt: Retrying in 1000ms (attempt 1/3)")
139
+ )
140
+ })
141
+ })
102
142
  })
@@ -1,6 +1,9 @@
1
1
  import { beforeEach, describe, expect, it, MockInstance, vi } from "vitest"
2
2
 
3
+ import { clearCachedConfig, getCachedConfig } from "#config/config.ts"
3
4
  import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
5
+ import * as login from "#modules/login.ts"
6
+ import * as logout from "#modules/logout.ts"
4
7
  import * as build from "#modules/search-templates/build.ts"
5
8
  import * as dev from "#modules/search-templates/dev.ts"
6
9
  import * as pull from "#modules/search-templates/pull.ts"
@@ -14,6 +17,8 @@ import { setupMockFileSystem } from "./utils/mockFileSystem.ts"
14
17
  const fs = setupMockFileSystem()
15
18
  const commander = setupMockCommander()
16
19
 
20
+ let loginSpy: MockInstance
21
+ let logoutSpy: MockInstance
17
22
  let setupSpy: MockInstance
18
23
  let statusSpy: MockInstance
19
24
  let buildSpy: MockInstance
@@ -22,14 +27,36 @@ let pushSpy: MockInstance
22
27
  let devSpy: MockInstance
23
28
 
24
29
  describe("commander", () => {
25
- // Make sure the actual functions are never called
26
30
  beforeEach(() => {
31
+ loginSpy = vi.spyOn(login, "loginToPlaycart").mockImplementation(() => Promise.resolve())
32
+ logoutSpy = vi.spyOn(logout, "removeLoginCredentials").mockImplementation(() => Promise.resolve())
27
33
  setupSpy = vi.spyOn(setup, "printSetupHelp").mockImplementation(() => Promise.resolve())
28
34
  statusSpy = vi.spyOn(status, "printStatus").mockImplementation(() => Promise.resolve())
29
35
  buildSpy = vi.spyOn(build, "buildSearchTemplate").mockImplementation(() => Promise.resolve())
30
36
  pullSpy = vi.spyOn(pull, "pullSearchTemplate").mockImplementation(() => Promise.resolve())
31
37
  pushSpy = vi.spyOn(push, "pushSearchTemplate").mockImplementation(() => Promise.resolve())
32
38
  devSpy = vi.spyOn(dev, "searchTemplateDevMode").mockImplementation(() => Promise.resolve())
39
+ clearCachedConfig()
40
+ fs.mockUserAuthentication()
41
+ })
42
+
43
+ describe("nosto login", () => {
44
+ it("should call the function", async () => {
45
+ await commander.run("nosto login")
46
+ expect(loginSpy).toHaveBeenCalledWith()
47
+ })
48
+
49
+ it("should load the config", async () => {
50
+ await commander.run("nosto login --verbose")
51
+ expect(getCachedConfig().verbose).toBe(true)
52
+ })
53
+ })
54
+
55
+ describe("nosto logout", () => {
56
+ it("should call the function", async () => {
57
+ await commander.run("nosto logout")
58
+ expect(logoutSpy).toHaveBeenCalledWith()
59
+ })
33
60
  })
34
61
 
35
62
  describe("nosto setup", () => {
@@ -143,6 +170,9 @@ describe("commander", () => {
143
170
  })
144
171
 
145
172
  describe("nosto search-templates pull", () => {
173
+ beforeEach(() => {
174
+ fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
175
+ })
146
176
  it("should pull even without files present", async () => {
147
177
  await commander.run("nosto st pull")
148
178
  expect(pullSpy).toHaveBeenCalled()
@@ -150,7 +180,6 @@ describe("commander", () => {
150
180
 
151
181
  describe("with valid environment", () => {
152
182
  beforeEach(() => {
153
- fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
154
183
  fs.writeFile("index.js", "@nosto/preact")
155
184
  })
156
185
 
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { parseAuthFile } from "#config/authConfig.ts"
4
+ import { AuthConfig } from "#config/schema.ts"
5
+ import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
6
+ import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
7
+
8
+ const fs = setupMockFileSystem()
9
+
10
+ describe("Auth Config", () => {
11
+ it("should throw if auth file does not exist", () => {
12
+ expect(() => parseAuthFile({})).toThrowError(MissingConfigurationError)
13
+ expect(() => parseAuthFile({ allowIncomplete: false })).toThrowError(MissingConfigurationError)
14
+ })
15
+
16
+ it("with allowIncomplete, should return empty auth config if auth file does not exist", () => {
17
+ const authConfig = parseAuthFile({ allowIncomplete: true })
18
+ expect(authConfig).toEqual({ user: "", token: "", expiresAt: new Date(0) })
19
+ })
20
+
21
+ it("should parse a valid auth file", () => {
22
+ const config = {
23
+ user: "testuser@nosto.com",
24
+ token: "testtoken",
25
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60)
26
+ } satisfies AuthConfig
27
+
28
+ fs.writeFile(fs.paths.authFile, config)
29
+
30
+ const authConfig = parseAuthFile({})
31
+ expect(authConfig).toEqual(config)
32
+ })
33
+
34
+ it("should throw an error for invalid auth file JSON", () => {
35
+ fs.writeFile(fs.paths.authFile, "{ invalidJson: true ")
36
+
37
+ expect(() => parseAuthFile({})).toThrowError(/Invalid JSON in auth file/)
38
+ })
39
+
40
+ it("should throw an error for auth file with invalid schema", () => {
41
+ const invalidConfig = {
42
+ user: ""
43
+ }
44
+
45
+ fs.writeFile(fs.paths.authFile, invalidConfig)
46
+
47
+ expect(() => parseAuthFile({})).toThrowError(/Invalid auth file/)
48
+ })
49
+
50
+ it("should throw an error for a malformed file", () => {
51
+ fs.writeFile(fs.paths.authFile, "\xff\xfe\xff\xfe")
52
+
53
+ expect(() => parseAuthFile({})).toThrowError(/Unexpected token/)
54
+ })
55
+
56
+ it("should throw an error when filesystem throws", () => {
57
+ fs.writeFile(fs.paths.authFile, {})
58
+ fs.chmod(fs.paths.authFile, 0o000) // Remove all permissions
59
+
60
+ expect(() => parseAuthFile({})).toThrowError(/permission denied/)
61
+ })
62
+ })
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ import { clearCachedConfig, getCachedConfig, getCachedSearchTemplatesConfig, loadConfig } from "#config/config.ts"
4
+ import { PersistentConfigSchema } from "#config/schema.ts"
5
+ import { setupMockConsole } from "#test/utils/mockConsole.ts"
6
+ import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
7
+
8
+ const fs = setupMockFileSystem()
9
+ const terminal = setupMockConsole()
10
+
11
+ describe("Config", () => {
12
+ beforeEach(() => {
13
+ clearCachedConfig()
14
+ })
15
+
16
+ afterEach(() => {
17
+ // Clean up environment variables
18
+ delete process.env.NOSTO_MAX_REQUESTS
19
+ vi.restoreAllMocks()
20
+ })
21
+
22
+ it("uses cached config on subsequent calls", async () => {
23
+ fs.mockConfigFile()
24
+ fs.mockUserAuthentication()
25
+
26
+ await loadConfig({ projectPath: ".", options: {} })
27
+ expect(terminal.getSpy("debug")).not.toHaveBeenCalledWith("Using cached configuration")
28
+ await loadConfig({ projectPath: ".", options: {} })
29
+ expect(terminal.getSpy("debug")).toHaveBeenCalledWith("Using cached configuration")
30
+ })
31
+
32
+ it("returns cached config", async () => {
33
+ fs.mockConfigFile()
34
+ fs.mockUserAuthentication()
35
+
36
+ const config = await loadConfig({ projectPath: ".", options: {} })
37
+ expect(getCachedConfig()).toEqual(config)
38
+ })
39
+
40
+ it("returns cached search-templates build config", async () => {
41
+ fs.mockConfigFile()
42
+ fs.mockUserAuthentication()
43
+
44
+ const config = await loadConfig({ projectPath: ".", options: {} })
45
+ expect(getCachedSearchTemplatesConfig()).toEqual(config.searchTemplates.data)
46
+ })
47
+
48
+ it("throws error with cause when last-chance schema validation fails", async () => {
49
+ fs.mockUserAuthentication()
50
+ fs.mockConfigFile()
51
+
52
+ // Mock the PersistentConfigSchema.parse to throw an error
53
+ const mockParse = vi.spyOn(PersistentConfigSchema, "parse")
54
+ mockParse.mockImplementationOnce(() => {
55
+ throw new Error("Mocked schema validation error")
56
+ })
57
+
58
+ await expect(loadConfig({ projectPath: ".", options: {} })).rejects.toThrow("Failed to load configuration")
59
+ })
60
+
61
+ it("default implementation of modern config should throw", async () => {
62
+ fs.mockUserAuthentication()
63
+ fs.mockConfigFile()
64
+
65
+ const config = await loadConfig({ projectPath: ".", options: {} })
66
+ await expect(config.searchTemplates.data.onBuild()).rejects.toThrow(/not implemented/)
67
+ await expect(config.searchTemplates.data.onBuildWatch({ onAfterBuild: async () => {} })).rejects.toThrow(
68
+ /not implemented/
69
+ )
70
+ })
71
+ })