@nosto/nosto-cli 1.0.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.
Files changed (88) hide show
  1. package/.github/copilot-instructions.md +326 -0
  2. package/.github/dependabot.yml +9 -0
  3. package/.github/pull_request_template.md +12 -0
  4. package/.github/workflows/ci.yml +58 -0
  5. package/.github/workflows/release.yml +49 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.prettierrc +9 -0
  8. package/LICENSE +29 -0
  9. package/README.md +154 -0
  10. package/commitlint.config.js +4 -0
  11. package/eslint.config.js +36 -0
  12. package/package.json +63 -0
  13. package/src/api/library/fetchLibraryFile.ts +18 -0
  14. package/src/api/retry.ts +28 -0
  15. package/src/api/source/fetchSourceFile.ts +33 -0
  16. package/src/api/source/listSourceFiles.ts +13 -0
  17. package/src/api/source/putSourceFile.ts +14 -0
  18. package/src/api/source/schema.ts +10 -0
  19. package/src/api/utils.ts +52 -0
  20. package/src/bootstrap.sh +26 -0
  21. package/src/commander.ts +119 -0
  22. package/src/config/authConfig.ts +42 -0
  23. package/src/config/config.ts +109 -0
  24. package/src/config/envConfig.ts +23 -0
  25. package/src/config/fileConfig.ts +39 -0
  26. package/src/config/schema.ts +70 -0
  27. package/src/config/searchTemplatesConfig.ts +33 -0
  28. package/src/console/logger.ts +93 -0
  29. package/src/console/userPrompt.ts +16 -0
  30. package/src/errors/InvalidLoginResponseError.ts +14 -0
  31. package/src/errors/MissingConfigurationError.ts +14 -0
  32. package/src/errors/NostoError.ts +13 -0
  33. package/src/errors/NotNostoTemplateError.ts +15 -0
  34. package/src/errors/withErrorHandler.ts +50 -0
  35. package/src/exports.ts +8 -0
  36. package/src/filesystem/asserts/assertGitRepo.ts +19 -0
  37. package/src/filesystem/asserts/assertNostoTemplate.ts +34 -0
  38. package/src/filesystem/calculateTreeHash.ts +28 -0
  39. package/src/filesystem/esbuild.ts +37 -0
  40. package/src/filesystem/esbuildPlugins.ts +72 -0
  41. package/src/filesystem/filesystem.ts +40 -0
  42. package/src/filesystem/isIgnored.ts +65 -0
  43. package/src/filesystem/legacyUtils.ts +10 -0
  44. package/src/filesystem/loadLibrary.ts +31 -0
  45. package/src/filesystem/processInBatches.ts +38 -0
  46. package/src/filesystem/utils/getLoaderScript.ts +28 -0
  47. package/src/index.ts +3 -0
  48. package/src/modules/login.ts +87 -0
  49. package/src/modules/logout.ts +13 -0
  50. package/src/modules/search-templates/build.ts +61 -0
  51. package/src/modules/search-templates/dev.ts +50 -0
  52. package/src/modules/search-templates/pull.ts +89 -0
  53. package/src/modules/search-templates/push.ts +121 -0
  54. package/src/modules/setup.ts +96 -0
  55. package/src/modules/status.ts +71 -0
  56. package/src/utils/withSafeEnvironment.ts +22 -0
  57. package/test/api/fetchSourceFile.test.ts +30 -0
  58. package/test/api/putSourceFile.test.ts +34 -0
  59. package/test/api/retry.test.ts +102 -0
  60. package/test/api/utils.test.ts +27 -0
  61. package/test/commander.test.ts +271 -0
  62. package/test/config/envConfig.test.ts +62 -0
  63. package/test/config/fileConfig.test.ts +63 -0
  64. package/test/config/schema.test.ts +96 -0
  65. package/test/config/searchTemplatesConfig.test.ts +43 -0
  66. package/test/console/logger.test.ts +96 -0
  67. package/test/errors/withErrorHandler.test.ts +64 -0
  68. package/test/filesystem/filesystem.test.ts +53 -0
  69. package/test/filesystem/plugins.test.ts +35 -0
  70. package/test/index.test.ts +15 -0
  71. package/test/modules/search-templates/build.legacy.test.ts +74 -0
  72. package/test/modules/search-templates/build.modern.test.ts +33 -0
  73. package/test/modules/search-templates/dev.legacy.test.ts +75 -0
  74. package/test/modules/search-templates/dev.modern.test.ts +44 -0
  75. package/test/modules/search-templates/pull.test.ts +96 -0
  76. package/test/modules/search-templates/push.test.ts +109 -0
  77. package/test/modules/setup.test.ts +49 -0
  78. package/test/modules/status.test.ts +22 -0
  79. package/test/setup.ts +28 -0
  80. package/test/utils/generateEndpointMock.ts +60 -0
  81. package/test/utils/mockCommander.ts +22 -0
  82. package/test/utils/mockConfig.ts +37 -0
  83. package/test/utils/mockConsole.ts +65 -0
  84. package/test/utils/mockFileSystem.ts +52 -0
  85. package/test/utils/mockServer.ts +76 -0
  86. package/test/utils/mockStarterManifest.ts +42 -0
  87. package/tsconfig.json +20 -0
  88. package/vitest.config.ts +33 -0
@@ -0,0 +1,36 @@
1
+ import js from "@eslint/js"
2
+ import { defineConfig } from "eslint/config"
3
+ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
4
+ import simpleImportSort from "eslint-plugin-simple-import-sort"
5
+ import unusedImports from "eslint-plugin-unused-imports"
6
+ import tseslint from "typescript-eslint"
7
+
8
+ export default defineConfig([
9
+ {
10
+ files: ["**/*.ts"],
11
+ plugins: {
12
+ js,
13
+ "@typescript-eslint": tseslint.plugin
14
+ },
15
+ extends: ["js/recommended", ...tseslint.configs.recommended],
16
+ languageOptions: {
17
+ parser: tseslint.parser,
18
+ parserOptions: {
19
+ project: "./tsconfig.json"
20
+ }
21
+ }
22
+ },
23
+ {
24
+ plugins: {
25
+ "unused-imports": unusedImports,
26
+ "simple-import-sort": simpleImportSort
27
+ },
28
+ rules: {
29
+ "unused-imports/no-unused-imports": "error",
30
+ "simple-import-sort/imports": "error",
31
+ "simple-import-sort/exports": "error",
32
+ "no-restricted-imports": ["error", "node:test"]
33
+ }
34
+ },
35
+ eslintPluginPrettierRecommended
36
+ ])
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@nosto/nosto-cli",
3
+ "version": "1.0.1",
4
+ "main": "./src/index.ts",
5
+ "bin": {
6
+ "nosto": "./src/bootstrap.sh"
7
+ },
8
+ "author": "Nosto",
9
+ "type": "module",
10
+ "imports": {
11
+ "#*": "./src/*"
12
+ },
13
+ "exports": {
14
+ ".": "./src/exports.ts"
15
+ },
16
+ "engines": {
17
+ "node": ">=24.0.0"
18
+ },
19
+ "license": "ISC",
20
+ "description": "",
21
+ "scripts": {
22
+ "lint": "eslint",
23
+ "lint:fix": "eslint --fix",
24
+ "prepare": "husky",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "test:ui": "vitest --ui",
28
+ "test:coverage": "vitest run --coverage",
29
+ "type-check": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "chalk": "^5.6.2",
33
+ "commander": "^14.0.1",
34
+ "esbuild": "^0.25.10",
35
+ "ignore": "^7.0.5",
36
+ "open": "^10.2.0",
37
+ "ky": "^1.11.0",
38
+ "preact": "^10.27.2",
39
+ "zod": "^4.1.11"
40
+ },
41
+ "devDependencies": {
42
+ "@commitlint/cli": "^20.1.0",
43
+ "@commitlint/config-conventional": "^20.0.0",
44
+ "@eslint/js": "^9.37.0",
45
+ "@types/node": "^24.7.0",
46
+ "@vitest/coverage-v8": "^3.2.4",
47
+ "@vitest/ui": "^3.2.4",
48
+ "eslint": "^9.37.0",
49
+ "eslint-config-prettier": "^10.1.8",
50
+ "eslint-plugin-prettier": "^5.5.4",
51
+ "eslint-plugin-simple-import-sort": "^12.1.1",
52
+ "eslint-plugin-unused-imports": "^4.2.0",
53
+ "husky": "^9.1.7",
54
+ "memfs": "^4.48.1",
55
+ "msw": "^2.11.3",
56
+ "prettier": "^3.6.2",
57
+ "typescript-eslint": "^8.45.0",
58
+ "vitest": "^3.2.4"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }
@@ -0,0 +1,18 @@
1
+ import ky from "ky"
2
+ import z from "zod"
3
+
4
+ import { cleanUrl, getJsonHeaders } from "#api/utils.ts"
5
+ import { getCachedConfig } from "#config/config.ts"
6
+
7
+ const FetchSourceFileSchema = z.string()
8
+
9
+ export async function fetchLibraryFile(path: string) {
10
+ const config = getCachedConfig()
11
+ const url = `${config.libraryUrl}/${cleanUrl(path)}`
12
+
13
+ const response = await ky.get(url.toString(), {
14
+ headers: getJsonHeaders()
15
+ })
16
+ const data = FetchSourceFileSchema.parse(await response.text())
17
+ return data
18
+ }
@@ -0,0 +1,28 @@
1
+ import chalk from "chalk"
2
+
3
+ import { Logger } from "#console/logger.ts"
4
+
5
+ const MAX_RETRIES = 3
6
+ const INITIAL_RETRY_DELAY = 1000 // 1 second
7
+
8
+ export async function fetchWithRetry(
9
+ apiFunction: (filePath: string) => Promise<string>,
10
+ filePath: string,
11
+ retryCount = 0
12
+ ): Promise<string> {
13
+ try {
14
+ return await apiFunction(filePath)
15
+ } catch (error: unknown) {
16
+ if (retryCount >= MAX_RETRIES) {
17
+ 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}`)
20
+ }
21
+ const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount)
22
+ Logger.warn(
23
+ `${chalk.yellow("⟳")} Failed to fetch ${chalk.cyan(filePath)}: Retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`
24
+ )
25
+ await new Promise(resolve => setTimeout(resolve, delay))
26
+ return fetchWithRetry(apiFunction, filePath, retryCount + 1)
27
+ }
28
+ }
@@ -0,0 +1,33 @@
1
+ import ky from "ky"
2
+ import z from "zod"
3
+
4
+ import { getJsonHeaders, getSourceUrl } from "#api/utils.ts"
5
+
6
+ const FetchSourceFileSchema = z.string()
7
+
8
+ export async function fetchSourceFile(path: string) {
9
+ const response = await ky.get(getSourceUrl(`source/{env}/${path}`), {
10
+ headers: getJsonHeaders()
11
+ })
12
+ const data = FetchSourceFileSchema.parse(await response.text())
13
+ return data
14
+ }
15
+
16
+ const KyErrorSchema = z.object({
17
+ response: z.object({
18
+ status: z.number(),
19
+ statusText: z.string()
20
+ })
21
+ })
22
+
23
+ export async function fetchSourceFileIfExists(path: string) {
24
+ try {
25
+ return await fetchSourceFile(path)
26
+ } catch (error) {
27
+ const parsedError = KyErrorSchema.safeParse(error)
28
+ if (parsedError.success && parsedError.data.response.status === 404) {
29
+ return null
30
+ }
31
+ throw error
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ import ky from "ky"
2
+
3
+ import { getJsonHeaders, getSourceUrl } from "#api/utils.ts"
4
+
5
+ import { ListSourceFilesSchema } from "./schema.ts"
6
+
7
+ export async function listSourceFiles() {
8
+ const response = await ky.get(getSourceUrl("source/{env}"), {
9
+ headers: getJsonHeaders()
10
+ })
11
+ const files = ListSourceFilesSchema.parse(await response.json())
12
+ return files.filter(file => file.path)
13
+ }
@@ -0,0 +1,14 @@
1
+ import ky from "ky"
2
+
3
+ import { getHeaders, getSourceUrl } from "#api/utils.ts"
4
+ import { getCachedConfig } from "#config/config.ts"
5
+
6
+ export async function putSourceFile(path: string, data: string) {
7
+ if (getCachedConfig().dryRun) {
8
+ return
9
+ }
10
+ await ky.put(getSourceUrl(`source/{env}/${path}`), {
11
+ headers: getHeaders(),
12
+ body: data
13
+ })
14
+ }
@@ -0,0 +1,10 @@
1
+ import z from "zod"
2
+
3
+ export const ListSourceFilesSchema = z.array(
4
+ z.object({
5
+ path: z.string(),
6
+ size: z.number()
7
+ })
8
+ )
9
+
10
+ export const ListLibraryFilesSchema = ListSourceFilesSchema
@@ -0,0 +1,52 @@
1
+ import { getCachedConfig } from "#config/config.ts"
2
+
3
+ export function getSourceUrl(path: string) {
4
+ const config = getCachedConfig()
5
+ const merchant = config.merchant
6
+ const env = config.templatesEnv
7
+ const replacedPath = path.replace("{env}", env)
8
+ return `${config.apiUrl}/${merchant}/search-templates/${replacedPath}`
9
+ }
10
+
11
+ export function getHeaders() {
12
+ return new Headers({
13
+ "Content-Type": "application/octet-stream",
14
+ ...getAuthHeaders()
15
+ })
16
+ }
17
+
18
+ export function getJsonHeaders() {
19
+ return new Headers({
20
+ "Content-Type": "application/json",
21
+ ...getAuthHeaders()
22
+ })
23
+ }
24
+
25
+ type AuthHeaders =
26
+ | {
27
+ Authorization: string
28
+ }
29
+ | {
30
+ "X-Nosto-User": string
31
+ "X-Nosto-Token": string
32
+ "X-Nosto-Merchant": string
33
+ }
34
+
35
+ function getAuthHeaders(): AuthHeaders {
36
+ const config = getCachedConfig()
37
+ if (config.apiKey) {
38
+ return {
39
+ Authorization: "Basic " + btoa(":" + config.apiKey)
40
+ }
41
+ }
42
+ return {
43
+ "X-Nosto-User": config.auth.user,
44
+ "X-Nosto-Token": config.auth.token,
45
+ "X-Nosto-Merchant": config.merchant
46
+ }
47
+ }
48
+
49
+ // Remove trailing and leading slashes
50
+ export function cleanUrl(url: string) {
51
+ return url.replace(/^\/+|\/+$/g, "")
52
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # A more robust way to get the script directory that works with symlinks
4
+ get_script_dir()
5
+ {
6
+ local SOURCE_PATH="${BASH_SOURCE[0]}"
7
+ local SYMLINK_DIR
8
+ local SCRIPT_DIR
9
+ # Resolve symlinks recursively
10
+ while [ -L "$SOURCE_PATH" ]; do
11
+ # Get symlink directory
12
+ SYMLINK_DIR="$( cd -P "$( dirname "$SOURCE_PATH" )" >/dev/null 2>&1 && pwd )"
13
+ # Resolve symlink target (relative or absolute)
14
+ SOURCE_PATH="$(readlink "$SOURCE_PATH")"
15
+ # Check if candidate path is relative or absolute
16
+ if [[ $SOURCE_PATH != /* ]]; then
17
+ # Candidate path is relative, resolve to full path
18
+ SOURCE_PATH=$SYMLINK_DIR/$SOURCE_PATH
19
+ fi
20
+ done
21
+ # Get final script directory path from fully resolved source path
22
+ SCRIPT_DIR="$(cd -P "$( dirname "$SOURCE_PATH" )" >/dev/null 2>&1 && pwd)"
23
+ echo "$SCRIPT_DIR"
24
+ }
25
+
26
+ exec node --experimental-strip-types --disable-warning=ExperimentalWarning "$(get_script_dir)/index.ts" "$@"
@@ -0,0 +1,119 @@
1
+ import { Command } from "commander"
2
+
3
+ import { loadConfig } from "#config/config.ts"
4
+ import { Logger } from "#console/logger.ts"
5
+ import { withErrorHandler } from "#errors/withErrorHandler.ts"
6
+ import { loginToPlaycart } from "#modules/login.ts"
7
+ import { removeLoginCredentials } from "#modules/logout.ts"
8
+ import { buildSearchTemplate } from "#modules/search-templates/build.ts"
9
+ import { searchTemplateDevMode } from "#modules/search-templates/dev.ts"
10
+ import { pullSearchTemplate } from "#modules/search-templates/pull.ts"
11
+ import { pushSearchTemplate } from "#modules/search-templates/push.ts"
12
+ import { printSetupHelp } from "#modules/setup.ts"
13
+ import { printStatus } from "#modules/status.ts"
14
+ import { withSafeEnvironment } from "#utils/withSafeEnvironment.ts"
15
+
16
+ export async function runCLI(argv: string[]) {
17
+ const program = new Command()
18
+ program.name("nostocli").version("1.0.0").description("Nosto CLI tool. Use `nostocli setup` to get started.")
19
+
20
+ program
21
+ .command("login")
22
+ .description("Login with your Nosto account")
23
+ .option("--verbose", "set log level to debug")
24
+ .action(async options => {
25
+ await loadConfig({ options, allowIncomplete: true, projectPath: "." })
26
+ await withErrorHandler(async () => {
27
+ await loginToPlaycart()
28
+ })
29
+ Logger.info("Login successful")
30
+ })
31
+
32
+ program
33
+ .command("logout")
34
+ .description("Delete stored login credentials")
35
+ .option("--verbose", "set log level to debug")
36
+ .action(() => {
37
+ removeLoginCredentials()
38
+ })
39
+
40
+ program
41
+ .command("setup [projectPath]")
42
+ .description("Prints setup information and creates a configuration file")
43
+ .option("-m, --merchant <merchant>", "merchant to create config for")
44
+ .action(async (projectPath = ".", options) => {
45
+ await loadConfig({ options, allowIncomplete: true, projectPath: "." })
46
+ await withErrorHandler(() => printSetupHelp(projectPath, options))
47
+ })
48
+
49
+ program
50
+ .command("status [projectPath]")
51
+ .description("Print the configuration status")
52
+ .action(async (projectPath = ".", options) => {
53
+ await loadConfig({ options, allowIncomplete: true, projectPath: "." })
54
+ await withErrorHandler(() => printStatus(projectPath))
55
+ })
56
+
57
+ const searchTemplates = program
58
+ .command("st")
59
+ .alias("search-templates")
60
+ .description("Search templates management commands")
61
+
62
+ searchTemplates
63
+ .command("pull [projectPath]")
64
+ .description("Pull the search-templates source from the Nosto VSCode Web")
65
+ .option("-p, --paths <files...>", "specific file paths to fetch (space-separated list)")
66
+ .option("--dry-run", "perform a dry run without making changes")
67
+ .option("--verbose", "set log level to debug")
68
+ .option("-f --force", "skip checking state, pull all files")
69
+ .action(async (projectPath = ".", options) => {
70
+ await withSafeEnvironment({ projectPath, options, skipSanityCheck: true }, async () => {
71
+ await pullSearchTemplate({
72
+ paths: options.paths ?? [],
73
+ force: options.force ?? false
74
+ })
75
+ })
76
+ })
77
+
78
+ searchTemplates
79
+ .command("push [projectPath]")
80
+ .description("Push the search-templates source to the VSCode Web")
81
+ .option("-p, --paths <files...>", "specific file paths to deploy (space-separated list)")
82
+ .option("--dry-run", "perform a dry run without making changes")
83
+ .option("--verbose", "set log level to debug")
84
+ .option("-f --force", "skip checking state, push all files")
85
+ .action(async (projectPath = ".", options) => {
86
+ await withSafeEnvironment({ projectPath, options }, async () => {
87
+ await buildSearchTemplate({ watch: false })
88
+ await pushSearchTemplate({
89
+ paths: options.paths ?? [],
90
+ force: options.force ?? false
91
+ })
92
+ })
93
+ })
94
+
95
+ searchTemplates
96
+ .command("build [projectPath]")
97
+ .description("Build the search-templates locally")
98
+ .option("--dry-run", "perform a dry run without making changes")
99
+ .option("--verbose", "set log level to debug")
100
+ .option("-w, --watch", "watch for changes and rebuild")
101
+ .action(async (projectPath = ".", options) => {
102
+ await withSafeEnvironment({ projectPath, options }, async () => {
103
+ await buildSearchTemplate({ watch: options.watch ?? false })
104
+ })
105
+ })
106
+
107
+ searchTemplates
108
+ .command("dev [projectPath]")
109
+ .description("Build the search-templates locally, watch for changes and continuously upload")
110
+ .option("--dry-run", "perform a dry run without making changes")
111
+ .option("--verbose", "set log level to debug")
112
+ .action(async (projectPath = ".", options) => {
113
+ await withSafeEnvironment({ projectPath, options }, async () => {
114
+ await searchTemplateDevMode()
115
+ })
116
+ })
117
+
118
+ await program.parseAsync(argv)
119
+ }
@@ -0,0 +1,42 @@
1
+ import fs from "fs"
2
+ import os from "os"
3
+ import path from "path"
4
+ import z from "zod"
5
+
6
+ import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
7
+
8
+ import { type AuthConfig, AuthConfigSchema } from "./schema.ts"
9
+
10
+ export const AuthConfigFilePath = path.join(os.homedir(), ".nosto", ".auth.json")
11
+
12
+ export function authFileExists() {
13
+ return fs.existsSync(AuthConfigFilePath)
14
+ }
15
+
16
+ export function getAuthFileMissingError() {
17
+ return new MissingConfigurationError(
18
+ `Auth file not found at: ${AuthConfigFilePath}. You will not be able to use most commands without running 'nosto login' first.`
19
+ )
20
+ }
21
+
22
+ export function parseAuthFile({ allowIncomplete }: { allowIncomplete?: boolean }): AuthConfig {
23
+ if (!allowIncomplete && !authFileExists()) {
24
+ throw getAuthFileMissingError()
25
+ } else if (!authFileExists()) {
26
+ return { user: "", token: "", expiresAt: new Date(0) }
27
+ }
28
+
29
+ try {
30
+ const configContent = fs.readFileSync(AuthConfigFilePath, "utf-8")
31
+ const rawConfig = JSON.parse(configContent)
32
+ return AuthConfigSchema.parse(rawConfig)
33
+ } catch (error) {
34
+ if (error instanceof z.ZodError) {
35
+ throw new Error(`Invalid auth file at ${AuthConfigFilePath}: ${error.message}`)
36
+ }
37
+ if (error instanceof SyntaxError) {
38
+ throw new Error(`Invalid JSON in auth file at ${AuthConfigFilePath}: ${error.message}`)
39
+ }
40
+ throw error
41
+ }
42
+ }
@@ -0,0 +1,109 @@
1
+ import { resolve } from "path"
2
+
3
+ import { cleanUrl } from "#api/utils.ts"
4
+ import { Logger } from "#console/logger.ts"
5
+ import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
6
+
7
+ import { authFileExists, getAuthFileMissingError, parseAuthFile } from "./authConfig.ts"
8
+ import { getEnvConfig } from "./envConfig.ts"
9
+ import { parseConfigFile } from "./fileConfig.ts"
10
+ import {
11
+ AuthConfigSchema,
12
+ type Config,
13
+ PersistentConfigSchema,
14
+ RuntimeConfigSchema,
15
+ SearchTemplatesConfigSchema
16
+ } from "./schema.ts"
17
+ import { parseSearchTemplatesConfigFile } from "./searchTemplatesConfig.ts"
18
+
19
+ let isConfigLoaded = false
20
+ let cachedConfig: Config = {
21
+ ...getDefaultConfig(),
22
+ auth: AuthConfigSchema.parse({
23
+ user: "",
24
+ token: "",
25
+ expiresAt: new Date(0)
26
+ }),
27
+ searchTemplates: {
28
+ mode: "unknown",
29
+ data: SearchTemplatesConfigSchema.parse({})
30
+ },
31
+ ...RuntimeConfigSchema.parse({})
32
+ }
33
+
34
+ export type LoadConfigProps = {
35
+ projectPath: string
36
+ options: object
37
+ allowIncomplete?: boolean
38
+ }
39
+
40
+ export async function loadConfig({ projectPath, options, allowIncomplete }: LoadConfigProps) {
41
+ const { dryRun, verbose } = RuntimeConfigSchema.parse({ ...options, projectPath })
42
+
43
+ if (isConfigLoaded) {
44
+ Logger.debug(`Using cached configuration`)
45
+ return cachedConfig
46
+ }
47
+
48
+ const fullPath = resolve(projectPath)
49
+ Logger.debug(`Loading configuration from folder: ${fullPath}`)
50
+ if (!allowIncomplete && !authFileExists()) {
51
+ throw getAuthFileMissingError()
52
+ }
53
+ const authConfig = parseAuthFile({ allowIncomplete })
54
+ const searchTemplatesConfig = await parseSearchTemplatesConfigFile({ projectPath })
55
+ const fileConfig = parseConfigFile({ projectPath, allowIncomplete })
56
+ const envConfig = getEnvConfig()
57
+
58
+ const baseConfig = allowIncomplete ? getDefaultConfig() : {}
59
+
60
+ const combinedConfig = {
61
+ ...baseConfig,
62
+ ...fileConfig,
63
+ ...envConfig
64
+ }
65
+ if (!combinedConfig.merchant && !allowIncomplete) {
66
+ throw new MissingConfigurationError("Invalid configuration: Missing merchant ID")
67
+ }
68
+
69
+ try {
70
+ const persistentConfig = PersistentConfigSchema.parse(combinedConfig)
71
+ cachedConfig = {
72
+ ...persistentConfig,
73
+ auth: authConfig,
74
+ searchTemplates: searchTemplatesConfig,
75
+ apiUrl: cleanUrl(persistentConfig.apiUrl),
76
+ libraryUrl: cleanUrl(persistentConfig.libraryUrl),
77
+ logLevel: verbose ? "debug" : persistentConfig.logLevel,
78
+ projectPath,
79
+ dryRun,
80
+ verbose
81
+ }
82
+ updateLoggerContext(cachedConfig)
83
+ isConfigLoaded = true
84
+ return cachedConfig
85
+ } catch (error) {
86
+ throw new Error("Failed to load configuration", { cause: error })
87
+ }
88
+ }
89
+
90
+ function updateLoggerContext(config: Config) {
91
+ Logger.context = {
92
+ logLevel: config.logLevel,
93
+ merchantId: config.merchant,
94
+ isDryRun: config.dryRun
95
+ }
96
+ }
97
+
98
+ export function getCachedConfig() {
99
+ return cachedConfig
100
+ }
101
+ export function getCachedSearchTemplatesConfig() {
102
+ return cachedConfig.searchTemplates.data
103
+ }
104
+
105
+ export function getDefaultConfig() {
106
+ return PersistentConfigSchema.parse({
107
+ merchant: ""
108
+ })
109
+ }
@@ -0,0 +1,23 @@
1
+ import { type EnvironmentConfig, EnvironmentConfigSchema } from "./schema.ts"
2
+
3
+ export const EnvVariables = {
4
+ apiKey: "NOSTO_API_KEY",
5
+ merchant: "NOSTO_MERCHANT",
6
+ templatesEnv: "NOSTO_TEMPLATES_ENV",
7
+ apiUrl: "NOSTO_API_URL",
8
+ libraryUrl: "NOSTO_LIBRARY_URL",
9
+ logLevel: "NOSTO_LOG_LEVEL",
10
+ maxRequests: "NOSTO_MAX_REQUESTS"
11
+ } satisfies Record<keyof EnvironmentConfig, string>
12
+
13
+ export function getEnvConfig(): EnvironmentConfig {
14
+ const config = Object.entries(EnvVariables).reduce<Record<string, string>>((acc, [key, envVar]) => {
15
+ const value = process.env[envVar]
16
+ if (value) {
17
+ acc[key] = value
18
+ }
19
+ return acc
20
+ }, {})
21
+
22
+ return EnvironmentConfigSchema.parse(config)
23
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import z from "zod"
4
+
5
+ import { Logger } from "#console/logger.ts"
6
+
7
+ import { type PartialPersistentConfig, PartialPersistentConfigSchema } from "./schema.ts"
8
+
9
+ export function parseConfigFile({
10
+ projectPath,
11
+ allowIncomplete
12
+ }: {
13
+ projectPath: string
14
+ allowIncomplete?: boolean
15
+ }): PartialPersistentConfig {
16
+ const configPath = path.join(projectPath, ".nosto.json")
17
+
18
+ const configFileMissing = !fs.existsSync(configPath)
19
+ if (allowIncomplete && configFileMissing) {
20
+ return {}
21
+ } else if (configFileMissing) {
22
+ Logger.warn(`Configuration file not found at: ${configPath}. Will try to use environment variables.`)
23
+ return {}
24
+ }
25
+
26
+ try {
27
+ const configContent = fs.readFileSync(configPath, "utf-8")
28
+ const rawConfig = JSON.parse(configContent)
29
+ return PartialPersistentConfigSchema.parse(rawConfig)
30
+ } catch (error) {
31
+ if (error instanceof z.ZodError) {
32
+ throw new Error(`Invalid configuration file at ${configPath}: ${error.message}`)
33
+ }
34
+ if (error instanceof SyntaxError) {
35
+ throw new Error(`Invalid JSON in configuration file at ${configPath}: ${error.message}`)
36
+ }
37
+ throw error
38
+ }
39
+ }