@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,38 @@
1
+ import chalk from "chalk"
2
+
3
+ import { getCachedConfig } from "#config/config.ts"
4
+ import { Logger } from "#console/logger.ts"
5
+
6
+ type Props = {
7
+ files: string[]
8
+ logIcon: string
9
+ processElement: (file: string) => Promise<unknown>
10
+ }
11
+
12
+ export async function processInBatches({ files, logIcon, processElement }: Props) {
13
+ const batchSize = getCachedConfig().maxRequests
14
+ const batches = []
15
+ let filesProcessed = 0
16
+ const totalFilesToPush = files.length
17
+ for (let i = 0; i < files.length; i += batchSize) {
18
+ batches.push(files.slice(i, i + batchSize))
19
+ }
20
+ for (let i = 0; i < batches.length; i++) {
21
+ const batch = batches[i]
22
+ const batchPromises = batch.map(async file => {
23
+ try {
24
+ await processElement(file)
25
+ filesProcessed += 1
26
+ Logger.info(`${chalk.green("✓")} [${filesProcessed}/${totalFilesToPush}] ${logIcon} ${chalk.cyan(file)}`)
27
+ } catch (error: unknown) {
28
+ const errorMessage = error instanceof Error ? error.message : String(error)
29
+ Logger.error(`${chalk.red("✗")} ${chalk.cyan(file)}: ${errorMessage}`)
30
+ }
31
+ })
32
+ const results = await Promise.allSettled(batchPromises)
33
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected")
34
+ if (failures.length > 0) {
35
+ Logger.warn(`Batch completed with ${failures.length} failures`)
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,28 @@
1
+ export function getLoaderScript(): Uint8Array {
2
+ return new TextEncoder().encode(
3
+ `
4
+ (function() {
5
+ if (window.nostoTemplatesLoaded) {
6
+ return;
7
+ };
8
+ window.nostoTemplatesLoaded = true;
9
+
10
+ var u = window.nostoTemplatesFileResolver || function(v) {
11
+ return new URL(v, document.currentScript.src).toString();
12
+ };
13
+
14
+ var j = document.createElement('script');
15
+ j.type = 'text/javascript';
16
+ j.src = window.nostoTemplatesFileResolver('bundle.js');
17
+ document.body.appendChild(j);
18
+
19
+ var c = document.createElement('link');
20
+ c.rel = 'stylesheet';
21
+ c.type = 'text/css';
22
+ c.media = 'all';
23
+ c.href = window.nostoTemplatesFileResolver('bundle.css');
24
+ document.head.appendChild(c);
25
+ })();
26
+ `.replace(/\s+/g, " ")
27
+ )
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { runCLI } from "#commander.ts"
2
+
3
+ runCLI(process.argv)
@@ -0,0 +1,87 @@
1
+ import http from "node:http"
2
+
3
+ import open from "open"
4
+ import z from "zod"
5
+
6
+ import { AuthConfigFilePath } from "#config/authConfig.ts"
7
+ import { getCachedConfig } from "#config/config.ts"
8
+ import { AuthConfigSchema } from "#config/schema.ts"
9
+ import { Logger } from "#console/logger.ts"
10
+ import { InvalidLoginResponseError } from "#errors/InvalidLoginResponseError.ts"
11
+ import { withErrorHandler } from "#errors/withErrorHandler.ts"
12
+ import { writeFile } from "#filesystem/filesystem.ts"
13
+
14
+ /**
15
+ * Playcart authentication flow works roughly as follows:
16
+ * - Internal web server is created by the CLI, listening on an ephemeral port
17
+ * - Browser page is opened to the Nosto login page with a redirect URI to localhost
18
+ * - After successful login (with 2FA), the redirect will hit the internal server
19
+ * - The response parameters are parsed from the query parameters
20
+ */
21
+ export async function loginToPlaycart() {
22
+ const server = await createAuthServer()
23
+ const config = getCachedConfig()
24
+ const redirectUri = `http://localhost:${server.port}`
25
+ const adminUrl = config.apiUrl.replace("://api.", "://my.").replace("/api", "")
26
+ const loginUrl = `${adminUrl}/admin/cli/redirect?target=${encodeURIComponent(redirectUri)}`
27
+
28
+ await open(loginUrl)
29
+
30
+ Logger.info("Awaiting response from the browser...")
31
+ const response = await server.responseData
32
+ writeFile(AuthConfigFilePath, JSON.stringify(response, null, 2) + "\n")
33
+
34
+ Logger.success(`Login successful! Auth file saved at ${AuthConfigFilePath}`)
35
+ }
36
+
37
+ type AuthServer = {
38
+ port: number
39
+ responseData: Promise<PlaycartResponse>
40
+ }
41
+
42
+ type PlaycartResponse = z.infer<typeof AuthConfigSchema>
43
+
44
+ /**
45
+ * The authentication server is created to handle a single redirect from the browser.
46
+ */
47
+ async function createAuthServer(): Promise<AuthServer> {
48
+ const { promise: tokenPromise, resolve: resolveTokenPromise } = Promise.withResolvers<PlaycartResponse>()
49
+
50
+ function handleRequest(res: http.ServerResponse, req: http.IncomingMessage) {
51
+ res.writeHead(200, { "content-type": "text/plain", connection: "close" })
52
+ res.end("You can now close this page and return to the CLI.\n")
53
+
54
+ const url = new URL(req.url ?? "", "http://localhost")
55
+ const parsed = AuthConfigSchema.safeParse({
56
+ user: url.searchParams.get("user"),
57
+ token: url.searchParams.get("token"),
58
+ expiresAt: url.searchParams.get("expiresAt")
59
+ })
60
+ if (!parsed.success) {
61
+ throw new InvalidLoginResponseError(`Failed to parse playcart response: ${parsed.error.message}`)
62
+ }
63
+ resolveTokenPromise(parsed.data)
64
+ }
65
+
66
+ const server = http.createServer((req, res) => {
67
+ withErrorHandler(() => {
68
+ handleRequest(res, req)
69
+ })
70
+ server.close()
71
+ })
72
+
73
+ const port = await new Promise<number>(resolve => {
74
+ server.listen(0, "localhost", () => {
75
+ const addr = server.address()
76
+ if (!addr || typeof addr !== "object") {
77
+ throw new Error("Failed to get server address")
78
+ }
79
+ resolve(addr.port)
80
+ })
81
+ })
82
+
83
+ return {
84
+ port,
85
+ responseData: tokenPromise
86
+ }
87
+ }
@@ -0,0 +1,13 @@
1
+ import fs from "fs"
2
+
3
+ import { AuthConfigFilePath } from "#config/authConfig.ts"
4
+ import { Logger } from "#console/logger.ts"
5
+
6
+ export function removeLoginCredentials() {
7
+ Logger.info(`Deleting stored login credentials at ${AuthConfigFilePath}`)
8
+ if (!fs.existsSync(AuthConfigFilePath)) {
9
+ Logger.warn(`File already deleted.`)
10
+ return
11
+ }
12
+ fs.unlinkSync(AuthConfigFilePath)
13
+ }
@@ -0,0 +1,61 @@
1
+ import path from "node:path"
2
+
3
+ import chalk from "chalk"
4
+
5
+ import { getCachedConfig, getCachedSearchTemplatesConfig } from "#config/config.ts"
6
+ import { Logger } from "#console/logger.ts"
7
+ import { getBuildContext } from "#filesystem/esbuild.ts"
8
+ import { isModernTemplateProject } from "#filesystem/legacyUtils.ts"
9
+ import { loadLibrary } from "#filesystem/loadLibrary.ts"
10
+
11
+ type Props = {
12
+ watch: boolean
13
+ }
14
+
15
+ export async function buildSearchTemplate({ watch }: Props) {
16
+ if (isModernTemplateProject()) {
17
+ await buildModernSearchTemplate({ watch })
18
+ } else {
19
+ await buildLegacySearchTemplate({ watch })
20
+ }
21
+ }
22
+
23
+ async function buildModernSearchTemplate({ watch }: Props) {
24
+ const { onBuild, onBuildWatch } = getCachedSearchTemplatesConfig()
25
+
26
+ if (watch) {
27
+ await onBuildWatch({
28
+ onAfterBuild: async () => {}
29
+ })
30
+ } else {
31
+ await onBuild()
32
+ }
33
+ }
34
+
35
+ async function buildLegacySearchTemplate({ watch }: Props) {
36
+ const { projectPath } = getCachedConfig()
37
+ const libraryPath = path.resolve(projectPath, ".nostocache/library")
38
+
39
+ Logger.info(`Fetching library to: ${chalk.cyan(libraryPath)}`)
40
+ await loadLibrary(libraryPath)
41
+
42
+ const targetPath = path.resolve(projectPath, "build")
43
+ Logger.info(`Building templates to: ${chalk.cyan(targetPath)}`)
44
+
45
+ const context = await getBuildContext()
46
+ if (!watch) {
47
+ await context.rebuild()
48
+ await context.dispose()
49
+ return
50
+ }
51
+
52
+ Logger.info(`Watching for changes. ${chalk.yellow("Press Ctrl+C to stop")}`)
53
+ await context.watch()
54
+
55
+ // Set up cleanup on process exit
56
+ process.on("SIGINT", () => {
57
+ context.dispose()
58
+ Logger.info(`${chalk.yellow("Watch mode stopped.")}`)
59
+ process.exit(0)
60
+ })
61
+ }
@@ -0,0 +1,50 @@
1
+ import chalk from "chalk"
2
+ import path from "path"
3
+
4
+ import { getCachedConfig, getCachedSearchTemplatesConfig } from "#config/config.ts"
5
+ import { Logger } from "#console/logger.ts"
6
+ import { getBuildContext } from "#filesystem/esbuild.ts"
7
+ import { pushOnRebuildPlugin } from "#filesystem/esbuildPlugins.ts"
8
+ import { isModernTemplateProject } from "#filesystem/legacyUtils.ts"
9
+ import { loadLibrary } from "#filesystem/loadLibrary.ts"
10
+
11
+ import { pushSearchTemplate } from "./push.ts"
12
+
13
+ export async function searchTemplateDevMode() {
14
+ if (isModernTemplateProject()) {
15
+ await modernSearchTemplateDevMode()
16
+ } else {
17
+ await legacySearchTemplateDevMode()
18
+ }
19
+ }
20
+
21
+ async function modernSearchTemplateDevMode() {
22
+ const { onBuildWatch } = getCachedSearchTemplatesConfig()
23
+
24
+ Logger.info(`Starting dev mode. ${chalk.yellow("Press Ctrl+C to stop")}`)
25
+
26
+ await onBuildWatch({
27
+ onAfterBuild: async () => {
28
+ pushSearchTemplate({ paths: ["build"], force: false })
29
+ }
30
+ })
31
+ }
32
+
33
+ async function legacySearchTemplateDevMode() {
34
+ const { projectPath } = getCachedConfig()
35
+ const libraryPath = path.resolve(projectPath, ".nostocache/library")
36
+
37
+ Logger.info(`Fetching library to: ${chalk.cyan(libraryPath)}`)
38
+ await loadLibrary(libraryPath)
39
+
40
+ Logger.info(`Watching for changes. ${chalk.yellow("Press Ctrl+C to stop")}`)
41
+
42
+ const context = await getBuildContext({ plugins: [pushOnRebuildPlugin()] })
43
+ await context.watch()
44
+
45
+ process.on("SIGINT", () => {
46
+ context.dispose()
47
+ Logger.info(`${chalk.yellow("Watch mode stopped.")}`)
48
+ process.exit(0)
49
+ })
50
+ }
@@ -0,0 +1,89 @@
1
+ import chalk from "chalk"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ import { fetchWithRetry } from "#api/retry.ts"
6
+ import { fetchSourceFile, fetchSourceFileIfExists } from "#api/source/fetchSourceFile.ts"
7
+ import { listSourceFiles } from "#api/source/listSourceFiles.ts"
8
+ import { getCachedConfig } from "#config/config.ts"
9
+ import { Logger } from "#console/logger.ts"
10
+ import { promptForConfirmation } from "#console/userPrompt.ts"
11
+ import { calculateTreeHash } from "#filesystem/calculateTreeHash.ts"
12
+ import { writeFile } from "#filesystem/filesystem.ts"
13
+ import { processInBatches } from "#filesystem/processInBatches.ts"
14
+
15
+ type PullSearchTemplateOptions = {
16
+ // Filter to only pull these files. Ignored if empty.
17
+ paths: string[]
18
+ // Skip checking the hash and pull all files.
19
+ force: boolean
20
+ }
21
+
22
+ /**
23
+ * Fetches the current templates to the specified target path.
24
+ * Processes files in parallel with controlled concurrency and retry logic.
25
+ */
26
+ export async function pullSearchTemplate({ paths, force }: PullSearchTemplateOptions) {
27
+ const { projectPath, dryRun } = getCachedConfig()
28
+ const targetFolder = path.resolve(projectPath)
29
+
30
+ // If the local and remote hashes match, assume the content matches as well
31
+ const localHash = calculateTreeHash()
32
+ const remoteHash = await fetchSourceFileIfExists("build/hash")
33
+ if (localHash === remoteHash && !force) {
34
+ Logger.success("Local template is already up to date.")
35
+ writeFile(path.join(targetFolder, ".nostocache/hash"), localHash)
36
+ return
37
+ }
38
+
39
+ Logger.info(`Fetching templates to: ${chalk.cyan(targetFolder)}`)
40
+
41
+ // Fetch the remote file list (filtered by paths if provided)
42
+ const baseFiles = await listSourceFiles()
43
+ const files = baseFiles.filter(file => paths.length === 0 || paths.includes(file.path))
44
+ Logger.info(`Found ${chalk.cyan(files.length)} source files to fetch.`)
45
+
46
+ // Check for existing files that will be overridden
47
+ const filesToOverride = files.filter(file => {
48
+ const targetFilePath = path.join(targetFolder, file.path)
49
+ return fs.existsSync(targetFilePath)
50
+ })
51
+
52
+ // Just for safety, show a warning if the user is about to override files.
53
+ if (filesToOverride.length > 0 && !force) {
54
+ Logger.warn(`${chalk.cyan(filesToOverride.length)} files will be overridden:`)
55
+
56
+ // Show first 10 files that will be overridden
57
+ const previewFiles = filesToOverride.slice(0, 10)
58
+ previewFiles.forEach(file => {
59
+ Logger.warn(`${chalk.yellow("•")} ${chalk.cyan(file.path)}`)
60
+ })
61
+ if (filesToOverride.length > 10) {
62
+ Logger.warn(`${chalk.yellow("...")} and ${chalk.cyan(filesToOverride.length - 10)} more`)
63
+ }
64
+
65
+ // Ask for confirmation
66
+ const confirmed = await promptForConfirmation(`Are you sure you want to override your local data?`, "N")
67
+ if (!confirmed && !force) {
68
+ Logger.info("Operation cancelled by user")
69
+ return
70
+ }
71
+ }
72
+
73
+ // Pull the files into batches to avoid overwhelming the API (relevant mostly for local dev)
74
+ await processInBatches({
75
+ files: files.map(file => file.path),
76
+ logIcon: chalk.blue("↓"),
77
+ processElement: async filePath => {
78
+ const data = await fetchWithRetry(fetchSourceFile, filePath)
79
+ const pathToWrite = path.join(targetFolder, filePath)
80
+ writeFile(pathToWrite, data)
81
+ }
82
+ })
83
+
84
+ // Write the hash
85
+ if (!dryRun && fs.existsSync(path.join(targetFolder, "build/hash"))) {
86
+ fs.mkdirSync(path.join(targetFolder, ".nostocache"), { recursive: true })
87
+ fs.copyFileSync(path.join(targetFolder, "build/hash"), path.join(targetFolder, ".nostocache/hash"))
88
+ }
89
+ }
@@ -0,0 +1,121 @@
1
+ import chalk from "chalk"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ import { fetchSourceFileIfExists } from "#api/source/fetchSourceFile.ts"
6
+ import { putSourceFile } from "#api/source/putSourceFile.ts"
7
+ import { getCachedConfig } from "#config/config.ts"
8
+ import { Logger } from "#console/logger.ts"
9
+ import { promptForConfirmation } from "#console/userPrompt.ts"
10
+ import { calculateTreeHash } from "#filesystem/calculateTreeHash.ts"
11
+ import { listAllFiles, readFileIfExists, writeFile } from "#filesystem/filesystem.ts"
12
+ import { processInBatches } from "#filesystem/processInBatches.ts"
13
+
14
+ const MAX_RETRIES = 3
15
+ const INITIAL_RETRY_DELAY = 1000 // 1 second
16
+
17
+ type PushSearchTemplateOptions = {
18
+ // Filter to only push these files. Ignored if empty.
19
+ paths: string[]
20
+ // Skip checking the hash and push all files.
21
+ force: boolean
22
+ }
23
+
24
+ /**
25
+ * Deploys templates to the specified target path.
26
+ * Processes files in parallel with controlled concurrency and retry logic.
27
+ */
28
+ export async function pushSearchTemplate({ paths, force }: PushSearchTemplateOptions) {
29
+ const { projectPath } = getCachedConfig()
30
+ const targetFolder = path.resolve(projectPath)
31
+
32
+ // List all files, excluding ignored and filtered by paths
33
+ const { allFiles, unfilteredFileCount } = listAllFiles(targetFolder)
34
+ const files = allFiles.filter(
35
+ file => paths.length === 0 || paths.includes(file) || paths.find(p => file.startsWith(p + "/"))
36
+ )
37
+
38
+ // Found literally no files -> nothing to do.
39
+ if (files.length === 0) {
40
+ Logger.warn("No files to push. Exiting.")
41
+ return
42
+ }
43
+
44
+ // If the local and remote hashes match, assume the content matches as well
45
+ const localHash = calculateTreeHash()
46
+ const remoteHash = await fetchSourceFileIfExists("build/hash")
47
+ if (localHash === remoteHash && !force) {
48
+ Logger.success("Remote template is already up to date.")
49
+ writeFile(path.join(targetFolder, ".nostocache/hash"), localHash)
50
+ return
51
+ }
52
+
53
+ /**
54
+ * If remote hash is present, we can check if there are conflicts. If not, just show the warning anyway.
55
+ * If remote hash doesn't match local hash, but remote hash matches last seen remote hash, then this is a clean push that shouldn't override anything.
56
+ * If .nostocache/hash is not present, it's a fresh checkout, but local state has changed already. Show the warning just in case.
57
+ * If remote hash doesn't match last seen remote hash, another user has pushed. Assume conflicts and show the warning.
58
+ */
59
+ const lastSeenRemoteHash = readFileIfExists(path.join(targetFolder, ".nostocache/hash"))
60
+ const shouldPrompt = !remoteHash || !lastSeenRemoteHash || remoteHash !== lastSeenRemoteHash
61
+ if (!force && shouldPrompt) {
62
+ let confirmationMessage = `It seems that the template has been changed since your last push. Are you sure you want to continue?`
63
+ if (!remoteHash || !lastSeenRemoteHash) {
64
+ confirmationMessage = `It seems that this is the first time you are pushing to this environment. Please make sure your local copy is up to date. Continue?`
65
+ }
66
+ const confirmed = await promptForConfirmation(confirmationMessage, "N")
67
+ if (!confirmed && !force) {
68
+ Logger.info("Push operation cancelled by user.")
69
+ return
70
+ }
71
+ }
72
+
73
+ Logger.info(`Pushing template from: ${chalk.cyan(targetFolder)}`)
74
+
75
+ // Update the hash files
76
+ writeFile(path.join(targetFolder, "build/hash"), localHash)
77
+ writeFile(path.join(targetFolder, ".nostocache/hash"), localHash)
78
+
79
+ // The hash file didn't exist, but now it does
80
+ if (!files.includes("build/hash")) {
81
+ files.push("build/hash")
82
+ }
83
+
84
+ // Collect some stats
85
+ const buildFileCount = files.filter(file => file.includes("build/")).length
86
+ const sourceFileCount = files.length - buildFileCount
87
+
88
+ const sourceFilesLabel = `${chalk.cyan(sourceFileCount)} source`
89
+ const builtFilesLabel = `${chalk.cyan(buildFileCount)} built`
90
+ const ignoredFilesLabel = `${chalk.cyan(unfilteredFileCount - files.length)} ignored`
91
+ Logger.info(
92
+ `Found ${chalk.cyan(files.length)} files to push (${sourceFilesLabel}, ${builtFilesLabel}, ${ignoredFilesLabel}).`
93
+ )
94
+
95
+ // Push the files in batches to avoid overwhelming the API (relevant mostly for local dev)
96
+ await processInBatches({
97
+ files,
98
+ logIcon: chalk.magenta("↑"),
99
+ processElement: async file => {
100
+ const filePath = path.join(targetFolder, file)
101
+ await putWithRetry(file, fs.readFileSync(filePath, "utf-8"))
102
+ }
103
+ })
104
+ }
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
+ }
@@ -0,0 +1,96 @@
1
+ import chalk from "chalk"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ import { getDefaultConfig } from "#config/config.ts"
6
+ import { EnvVariables, getEnvConfig } from "#config/envConfig.ts"
7
+ import { Logger } from "#console/logger.ts"
8
+ import { promptForConfirmation } from "#console/userPrompt.ts"
9
+ import { writeFile } from "#filesystem/filesystem.ts"
10
+
11
+ type Options = {
12
+ merchant?: string
13
+ }
14
+
15
+ export async function printSetupHelp(projectPath: string, options: Options) {
16
+ const defaultConfig = getDefaultConfig()
17
+
18
+ Logger.info(chalk.cyan(chalk.bold("Configuration Methods:")))
19
+ Logger.info(` • Configuration file (${chalk.cyan(".nosto.json")} in project root)`)
20
+ Logger.info(" • Environment variables\n")
21
+
22
+ Logger.info(chalk.bold("Note: ") + "Environment variables take precedence over the configuration file.\n")
23
+
24
+ // Required parameters
25
+ Logger.info(chalk.bold("Merchant ID:"))
26
+ Logger.info(` • Config file: ${chalk.cyan("merchant")}`)
27
+ Logger.info(` • Env variable: ${chalk.magenta(EnvVariables.merchant)}`)
28
+ Logger.info(` • Your merchant ID\n`)
29
+
30
+ // Optional parameters
31
+ Logger.info(chalk.yellow(chalk.bold("Optional Parameters:")))
32
+
33
+ Logger.info(chalk.bold("API Key:"))
34
+ Logger.info(` • Config file: ${chalk.cyan("apiKey")}`)
35
+ Logger.info(` • Env variable: ${chalk.magenta(EnvVariables.apiKey)}`)
36
+ Logger.info(` • Your Nosto API key if needed. Used for CI or automation.\n`)
37
+
38
+ Logger.info(chalk.bold("Templates Environment:"))
39
+ Logger.info(` • Config file: ${chalk.cyan("templatesEnv")}`)
40
+ Logger.info(` • Env variable: ${chalk.magenta(EnvVariables.templatesEnv)}`)
41
+ Logger.info(` • Nosto templates environment`)
42
+ Logger.info(` • Default: ${chalk.green(defaultConfig.templatesEnv)}\n`)
43
+
44
+ Logger.info(chalk.bold("API URL:"))
45
+ Logger.info(` • Config file: ${chalk.cyan("apiUrl")}`)
46
+ Logger.info(` • Env variable: ${chalk.magenta(EnvVariables.apiUrl)}`)
47
+ Logger.info(` • Nosto API URL`)
48
+ Logger.info(` • Default: ${chalk.green(defaultConfig.apiUrl)}\n`)
49
+
50
+ Logger.info(chalk.bold("Log Level:"))
51
+ Logger.info(` • Config file: ${chalk.cyan("logLevel")}`)
52
+ Logger.info(` • Env variable: ${chalk.magenta(EnvVariables.logLevel)}`)
53
+ Logger.info(` • Output log level`)
54
+ Logger.info(` • Default: ${chalk.green(defaultConfig.logLevel)}\n`)
55
+
56
+ Logger.info(chalk.bold("Max Requests:"))
57
+ Logger.info(` • Config file: ${chalk.cyan("maxRequests")}`)
58
+ Logger.info(` • Env variable: ${chalk.magenta(EnvVariables.maxRequests)}`)
59
+ Logger.info(` • Maximum number of requests in flight at the same time`)
60
+ Logger.info(` • Default: ${chalk.green(defaultConfig.maxRequests)}\n`)
61
+
62
+ const configFilePath = path.join(projectPath, ".nosto.json")
63
+ if (fs.existsSync(configFilePath)) {
64
+ Logger.info(`Configuration file already exists at ${chalk.cyan(configFilePath)}`)
65
+ return
66
+ }
67
+
68
+ const envConfig = getEnvConfig()
69
+ const configToCreate = defaultConfig
70
+ Object.entries(envConfig).forEach(([key, value]) => {
71
+ if (key in configToCreate) {
72
+ Object.assign(configToCreate, { [key]: value })
73
+ }
74
+ })
75
+
76
+ const { merchant } = options
77
+ if (merchant) {
78
+ configToCreate.merchant = merchant
79
+ } else {
80
+ Logger.warn("Configuration file not found in project directory.")
81
+ Logger.info(chalk.greenBright("Preview:"))
82
+ Logger.info(chalk.dim("{"))
83
+ Object.entries(configToCreate).forEach(([key, value]) => {
84
+ Logger.info(chalk.dim(` "${key}": "${value}",`))
85
+ })
86
+ Logger.info(chalk.dim("}"))
87
+ }
88
+
89
+ const confirmed = merchant || (await promptForConfirmation(`Would you like to create a configuration file?`, "Y"))
90
+ if (confirmed) {
91
+ writeFile(configFilePath, JSON.stringify(configToCreate, null, 2) + "\n")
92
+
93
+ const resolvedPath = path.resolve(configFilePath)
94
+ Logger.info(`Created configuration file in ${chalk.cyan(resolvedPath)}`)
95
+ }
96
+ }
@@ -0,0 +1,71 @@
1
+ import chalk from "chalk"
2
+
3
+ import { getCachedConfig, loadConfig } from "#config/config.ts"
4
+ import { Logger } from "#console/logger.ts"
5
+ import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
6
+
7
+ export async function printStatus(projectPath: string) {
8
+ try {
9
+ await loadConfig({ projectPath, options: {} })
10
+ } catch (error) {
11
+ if (error instanceof MissingConfigurationError) {
12
+ Logger.error("Some required configuration is missing\n")
13
+ } else {
14
+ throw error
15
+ }
16
+ }
17
+ const { apiKey, apiUrl, logLevel, maxRequests, auth, merchant, templatesEnv } = getCachedConfig()
18
+ const { user, token, expiresAt } = auth
19
+
20
+ const notSet = chalk.redBright("Not set")
21
+ const formattedApiKey = apiKey ? chalk.greenBright(apiKey.slice(0, 6) + "[...]" + apiKey.slice(-4)) : notSet
22
+ const authToken = token ? chalk.greenBright(token.slice(0, 6) + "[...]" + token.slice(-4)) : notSet
23
+ const merchantId = merchant ? chalk.greenBright(merchant) : notSet
24
+
25
+ // Authentication
26
+ Logger.info(chalk.yellow("Authentication:"))
27
+ Logger.info(` ${chalk.bold("User:")} ${user ? chalk.cyan(user) : notSet}`)
28
+ Logger.info(` ${chalk.bold("Token:")} ${token ? authToken : notSet}`)
29
+ Logger.info(` ${chalk.bold("Expires At:")} ${expiresAt.getTime() > 0 ? chalk.cyan(expiresAt) : notSet}`)
30
+ Logger.info("")
31
+
32
+ // Required settings
33
+ Logger.info(chalk.yellow("Required Settings:"))
34
+ Logger.info(` ${chalk.bold("Merchant ID:")} ${merchantId}\n`)
35
+
36
+ // Optional settings
37
+ Logger.info(chalk.yellow("Optional Settings:"))
38
+ Logger.info(` ${chalk.bold("API Key:")} ${formattedApiKey}`)
39
+ Logger.info(` ${chalk.bold("Templates Env:")} ${chalk.cyan(templatesEnv)}`)
40
+ Logger.info(` ${chalk.bold("API URL:")} ${chalk.cyan(apiUrl)}`)
41
+ Logger.info(` ${chalk.bold("Log Level:")} ${chalk.cyan(logLevel)}`)
42
+ Logger.info(` ${chalk.bold("Max Requests:")} ${chalk.cyan(maxRequests)}`)
43
+
44
+ Logger.info("")
45
+ const userAuthPresent = user && token && expiresAt.getTime() > 0
46
+ const userAuthValid = userAuthPresent && expiresAt.getTime() > Date.now()
47
+ const errors = []
48
+ if (!merchant) {
49
+ errors.push("Invalid configuration: Missing merchant ID")
50
+ }
51
+ if (!apiKey && !userAuthPresent) {
52
+ errors.push("Invalid configuration: Missing authentication (use `nosto login` or provide an API key)")
53
+ }
54
+ if (!apiKey && userAuthPresent && !userAuthValid) {
55
+ errors.push("Invalid configuration: Authentication token expired")
56
+ }
57
+
58
+ if (errors.length == 0) {
59
+ Logger.info(chalk.dim("Configuration seems to be valid:"))
60
+ if (apiKey) {
61
+ Logger.info(chalk.dim(" - Using API key for authentication"))
62
+ } else {
63
+ Logger.info(chalk.dim(" - Using user account for authentication"))
64
+ }
65
+ } else {
66
+ Logger.info(chalk.red("Configuration is not valid:"))
67
+ for (const error of errors) {
68
+ Logger.info(chalk.red(" - " + error))
69
+ }
70
+ }
71
+ }