@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.
- package/.github/copilot-instructions.md +326 -0
- package/.github/dependabot.yml +9 -0
- package/.github/pull_request_template.md +12 -0
- package/.github/workflows/ci.yml +58 -0
- package/.github/workflows/release.yml +49 -0
- package/.husky/commit-msg +1 -0
- package/.prettierrc +9 -0
- package/LICENSE +29 -0
- package/README.md +154 -0
- package/commitlint.config.js +4 -0
- package/eslint.config.js +36 -0
- package/package.json +63 -0
- package/src/api/library/fetchLibraryFile.ts +18 -0
- package/src/api/retry.ts +28 -0
- package/src/api/source/fetchSourceFile.ts +33 -0
- package/src/api/source/listSourceFiles.ts +13 -0
- package/src/api/source/putSourceFile.ts +14 -0
- package/src/api/source/schema.ts +10 -0
- package/src/api/utils.ts +52 -0
- package/src/bootstrap.sh +26 -0
- package/src/commander.ts +119 -0
- package/src/config/authConfig.ts +42 -0
- package/src/config/config.ts +109 -0
- package/src/config/envConfig.ts +23 -0
- package/src/config/fileConfig.ts +39 -0
- package/src/config/schema.ts +70 -0
- package/src/config/searchTemplatesConfig.ts +33 -0
- package/src/console/logger.ts +93 -0
- package/src/console/userPrompt.ts +16 -0
- package/src/errors/InvalidLoginResponseError.ts +14 -0
- package/src/errors/MissingConfigurationError.ts +14 -0
- package/src/errors/NostoError.ts +13 -0
- package/src/errors/NotNostoTemplateError.ts +15 -0
- package/src/errors/withErrorHandler.ts +50 -0
- package/src/exports.ts +8 -0
- package/src/filesystem/asserts/assertGitRepo.ts +19 -0
- package/src/filesystem/asserts/assertNostoTemplate.ts +34 -0
- package/src/filesystem/calculateTreeHash.ts +28 -0
- package/src/filesystem/esbuild.ts +37 -0
- package/src/filesystem/esbuildPlugins.ts +72 -0
- package/src/filesystem/filesystem.ts +40 -0
- package/src/filesystem/isIgnored.ts +65 -0
- package/src/filesystem/legacyUtils.ts +10 -0
- package/src/filesystem/loadLibrary.ts +31 -0
- package/src/filesystem/processInBatches.ts +38 -0
- package/src/filesystem/utils/getLoaderScript.ts +28 -0
- package/src/index.ts +3 -0
- package/src/modules/login.ts +87 -0
- package/src/modules/logout.ts +13 -0
- package/src/modules/search-templates/build.ts +61 -0
- package/src/modules/search-templates/dev.ts +50 -0
- package/src/modules/search-templates/pull.ts +89 -0
- package/src/modules/search-templates/push.ts +121 -0
- package/src/modules/setup.ts +96 -0
- package/src/modules/status.ts +71 -0
- package/src/utils/withSafeEnvironment.ts +22 -0
- package/test/api/fetchSourceFile.test.ts +30 -0
- package/test/api/putSourceFile.test.ts +34 -0
- package/test/api/retry.test.ts +102 -0
- package/test/api/utils.test.ts +27 -0
- package/test/commander.test.ts +271 -0
- package/test/config/envConfig.test.ts +62 -0
- package/test/config/fileConfig.test.ts +63 -0
- package/test/config/schema.test.ts +96 -0
- package/test/config/searchTemplatesConfig.test.ts +43 -0
- package/test/console/logger.test.ts +96 -0
- package/test/errors/withErrorHandler.test.ts +64 -0
- package/test/filesystem/filesystem.test.ts +53 -0
- package/test/filesystem/plugins.test.ts +35 -0
- package/test/index.test.ts +15 -0
- package/test/modules/search-templates/build.legacy.test.ts +74 -0
- package/test/modules/search-templates/build.modern.test.ts +33 -0
- package/test/modules/search-templates/dev.legacy.test.ts +75 -0
- package/test/modules/search-templates/dev.modern.test.ts +44 -0
- package/test/modules/search-templates/pull.test.ts +96 -0
- package/test/modules/search-templates/push.test.ts +109 -0
- package/test/modules/setup.test.ts +49 -0
- package/test/modules/status.test.ts +22 -0
- package/test/setup.ts +28 -0
- package/test/utils/generateEndpointMock.ts +60 -0
- package/test/utils/mockCommander.ts +22 -0
- package/test/utils/mockConfig.ts +37 -0
- package/test/utils/mockConsole.ts +65 -0
- package/test/utils/mockFileSystem.ts +52 -0
- package/test/utils/mockServer.ts +76 -0
- package/test/utils/mockStarterManifest.ts +42 -0
- package/tsconfig.json +20 -0
- 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,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
|
+
}
|