@nosto/nosto-cli 1.1.0 → 1.2.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.
- package/package.json +4 -2
- package/src/api/deployments/createDeployment.ts +12 -0
- package/src/api/deployments/listDeployments.ts +12 -0
- package/src/api/deployments/rollbackDeployment.ts +11 -0
- package/src/api/deployments/schema.ts +12 -0
- package/src/api/deployments/updateDeployment.ts +11 -0
- package/src/api/retry.ts +5 -0
- package/src/commander.ts +61 -1
- package/src/console/spinner.ts +14 -0
- package/src/console/userPrompt.ts +12 -1
- package/src/filesystem/homeDirectory.ts +4 -1
- package/src/modules/deployments/deploy.ts +80 -0
- package/src/modules/deployments/list.ts +41 -0
- package/src/modules/deployments/redeploy.ts +103 -0
- package/src/modules/deployments/rollback.ts +29 -0
- package/src/modules/search-templates/build.ts +18 -5
- package/src/utils/formatDate.ts +17 -0
- package/src/utils/validations.ts +8 -0
- package/test/commander.test.ts +1 -1
- package/test/console/spinner.test.ts +55 -0
- package/test/setup.ts +7 -1
- package/test/utils/mockConsole.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosto/nosto-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"main": "./src/index.ts",
|
|
5
5
|
"bin": {
|
|
6
6
|
"nosto": "./src/bootstrap.mjs"
|
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
"type-check": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@inquirer/prompts": "^8.0.1",
|
|
32
33
|
"chalk": "^5.6.2",
|
|
33
34
|
"commander": "^14.0.1",
|
|
34
35
|
"esbuild": "^0.27.0",
|
|
35
36
|
"ignore": "^7.0.5",
|
|
36
37
|
"ky": "^1.11.0",
|
|
37
|
-
"open": "^
|
|
38
|
+
"open": "^11.0.0",
|
|
39
|
+
"ora": "^9.0.0",
|
|
38
40
|
"preact": "^10.27.2",
|
|
39
41
|
"tsx": "^4.20.6",
|
|
40
42
|
"zod": "^4.1.11"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ky from "ky"
|
|
2
|
+
|
|
3
|
+
import { getJsonHeaders, getSourceUrl } from "#api/utils.ts"
|
|
4
|
+
|
|
5
|
+
export async function createDeployment({ path, description }: { path: string; description: string }) {
|
|
6
|
+
const url = getSourceUrl(`deployments/{env}/${path}`)
|
|
7
|
+
|
|
8
|
+
await ky.post(url, {
|
|
9
|
+
headers: getJsonHeaders(),
|
|
10
|
+
body: JSON.stringify({ description })
|
|
11
|
+
})
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ky from "ky"
|
|
2
|
+
|
|
3
|
+
import { getJsonHeaders, getSourceUrl } from "#api/utils.ts"
|
|
4
|
+
|
|
5
|
+
import { ListDeploymentsSchema } from "./schema.ts"
|
|
6
|
+
|
|
7
|
+
export async function listDeployments() {
|
|
8
|
+
const response = await ky.get(getSourceUrl("deployments/{env}"), {
|
|
9
|
+
headers: getJsonHeaders()
|
|
10
|
+
})
|
|
11
|
+
return ListDeploymentsSchema.parse(await response.json())
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import ky from "ky"
|
|
2
|
+
|
|
3
|
+
import { getJsonHeaders, getSourceUrl } from "#api/utils.ts"
|
|
4
|
+
|
|
5
|
+
export async function updateDeployment(deploymentId: string) {
|
|
6
|
+
const url = getSourceUrl(`deployment/{env}/${deploymentId}`)
|
|
7
|
+
|
|
8
|
+
await ky.post(url, {
|
|
9
|
+
headers: getJsonHeaders()
|
|
10
|
+
})
|
|
11
|
+
}
|
package/src/api/retry.ts
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from "chalk"
|
|
|
2
2
|
|
|
3
3
|
import { Logger } from "#console/logger.ts"
|
|
4
4
|
|
|
5
|
+
import { createDeployment } from "./deployments/createDeployment.ts"
|
|
5
6
|
import { putSourceFile } from "./source/putSourceFile.ts"
|
|
6
7
|
|
|
7
8
|
const MAX_RETRIES = 3
|
|
@@ -42,3 +43,7 @@ export async function fetchWithRetry(
|
|
|
42
43
|
export async function putWithRetry(filePath: string, content: string): Promise<void> {
|
|
43
44
|
return executeWithRetry(() => putSourceFile(filePath, content), filePath, "push")
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
export async function deployWithRetry(path: string, description: string): Promise<void> {
|
|
48
|
+
return executeWithRetry(() => createDeployment({ path, description }), path, "push")
|
|
49
|
+
}
|
package/src/commander.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { Command } from "commander"
|
|
|
3
3
|
import { loadConfig } from "#config/config.ts"
|
|
4
4
|
import { Logger } from "#console/logger.ts"
|
|
5
5
|
import { withErrorHandler } from "#errors/withErrorHandler.ts"
|
|
6
|
+
import { deploymentsDeploy } from "#modules/deployments/deploy.ts"
|
|
7
|
+
import { deploymentsList } from "#modules/deployments/list.ts"
|
|
8
|
+
import { deploymentsRedeploy } from "#modules/deployments/redeploy.ts"
|
|
9
|
+
import { deploymentsRollback } from "#modules/deployments/rollback.ts"
|
|
6
10
|
import { loginToPlaycart } from "#modules/login.ts"
|
|
7
11
|
import { removeLoginCredentials } from "#modules/logout.ts"
|
|
8
12
|
import { buildSearchTemplate } from "#modules/search-templates/build.ts"
|
|
@@ -54,6 +58,61 @@ export async function runCLI(argv: string[]) {
|
|
|
54
58
|
await withErrorHandler(() => printStatus(projectPath))
|
|
55
59
|
})
|
|
56
60
|
|
|
61
|
+
const deployments = program.command("dp").alias("deployments").description("Deployments related commands")
|
|
62
|
+
|
|
63
|
+
deployments
|
|
64
|
+
.command("list [projectPath]")
|
|
65
|
+
.description("List all deployments for a project")
|
|
66
|
+
.option("--verbose", "set log level to debug")
|
|
67
|
+
.action(async (projectPath = ".", options) => {
|
|
68
|
+
await withSafeEnvironment({ projectPath, options, skipSanityCheck: true }, async () => {
|
|
69
|
+
await deploymentsList()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
deployments
|
|
74
|
+
.command("deploy [projectPath]")
|
|
75
|
+
.description("Deploy a project")
|
|
76
|
+
.option("-d, --description <description>", "description for the deployment")
|
|
77
|
+
.option("--verbose", "set log level to debug")
|
|
78
|
+
.option("-f, --force", "skip confirmation prompt")
|
|
79
|
+
.action(async (projectPath = ".", options) => {
|
|
80
|
+
await withSafeEnvironment({ projectPath, options, skipSanityCheck: true }, async () => {
|
|
81
|
+
await deploymentsDeploy({
|
|
82
|
+
description: options.description,
|
|
83
|
+
force: options.force ?? false
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
deployments
|
|
89
|
+
.command("redeploy [projectPath]")
|
|
90
|
+
.description("Redeploy an existing deployment")
|
|
91
|
+
.option("-i, --id <deploymentId>", "deployment ID to redeploy (skips interactive selection)")
|
|
92
|
+
.option("--verbose", "set log level to debug")
|
|
93
|
+
.option("-f, --force", "skip confirmation prompt")
|
|
94
|
+
.action(async (projectPath = ".", options) => {
|
|
95
|
+
await withSafeEnvironment({ projectPath, options, skipSanityCheck: true }, async () => {
|
|
96
|
+
await deploymentsRedeploy({
|
|
97
|
+
deploymentId: options.id,
|
|
98
|
+
force: options.force ?? false
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
deployments
|
|
104
|
+
.command("disable [projectPath]")
|
|
105
|
+
.description("Disable the currently active deployment")
|
|
106
|
+
.option("--verbose", "set log level to debug")
|
|
107
|
+
.option("-f, --force", "skip confirmation prompt")
|
|
108
|
+
.action(async (projectPath = ".", options) => {
|
|
109
|
+
await withSafeEnvironment({ projectPath, options, skipSanityCheck: true }, async () => {
|
|
110
|
+
await deploymentsRollback({
|
|
111
|
+
force: options.force ?? false
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
57
116
|
const searchTemplates = program
|
|
58
117
|
.command("st")
|
|
59
118
|
.alias("search-templates")
|
|
@@ -98,9 +157,10 @@ export async function runCLI(argv: string[]) {
|
|
|
98
157
|
.option("--dry-run", "perform a dry run without making changes")
|
|
99
158
|
.option("--verbose", "set log level to debug")
|
|
100
159
|
.option("-w, --watch", "watch for changes and rebuild")
|
|
160
|
+
.option("-p, --push", "automatically push build artifacts after building")
|
|
101
161
|
.action(async (projectPath = ".", options) => {
|
|
102
162
|
await withSafeEnvironment({ projectPath, options }, async () => {
|
|
103
|
-
await buildSearchTemplate({ watch: options.watch ?? false })
|
|
163
|
+
await buildSearchTemplate({ watch: options.watch ?? false, push: options.push ?? false })
|
|
104
164
|
})
|
|
105
165
|
})
|
|
106
166
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ora from "ora"
|
|
2
|
+
|
|
3
|
+
export async function withSpinner<T>(text: string, operation: () => Promise<T>) {
|
|
4
|
+
const spinner = ora(text).start()
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const result = await operation()
|
|
8
|
+
spinner.succeed()
|
|
9
|
+
return result
|
|
10
|
+
} catch (error) {
|
|
11
|
+
spinner.fail()
|
|
12
|
+
throw error
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk"
|
|
2
2
|
import { createInterface } from "readline/promises"
|
|
3
3
|
|
|
4
|
-
export async function promptForConfirmation(message: string, defaultAnswer: "Y" | "N")
|
|
4
|
+
export async function promptForConfirmation(message: string, defaultAnswer: "Y" | "N") {
|
|
5
5
|
const rl = createInterface({
|
|
6
6
|
input: process.stdin,
|
|
7
7
|
output: process.stdout
|
|
@@ -14,3 +14,14 @@ export async function promptForConfirmation(message: string, defaultAnswer: "Y"
|
|
|
14
14
|
const evaluatedAnswer = answer.length === 0 ? defaultAnswer : answer.toUpperCase()
|
|
15
15
|
return evaluatedAnswer === "Y"
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
export async function promptForInput(message: string) {
|
|
19
|
+
const rl = createInterface({
|
|
20
|
+
input: process.stdin,
|
|
21
|
+
output: process.stdout
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const answer = await rl.question("\n" + message + " ")
|
|
25
|
+
rl.close()
|
|
26
|
+
return answer.trim()
|
|
27
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import os from "os"
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `import.meta.env` is NOT defined in production builds
|
|
5
|
+
*/
|
|
6
|
+
export const HomeDirectory = import.meta.env?.MODE === "test" ? "/vitest/home" : os.homedir()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import path from "path"
|
|
3
|
+
|
|
4
|
+
import { deployWithRetry } from "#api/retry.ts"
|
|
5
|
+
import { fetchSourceFileIfExists } from "#api/source/fetchSourceFile.ts"
|
|
6
|
+
import { getCachedConfig } from "#config/config.ts"
|
|
7
|
+
import { Logger } from "#console/logger.ts"
|
|
8
|
+
import { withSpinner } from "#console/spinner.ts"
|
|
9
|
+
import { promptForConfirmation, promptForInput } from "#console/userPrompt.ts"
|
|
10
|
+
import { calculateTreeHash } from "#filesystem/calculateTreeHash.ts"
|
|
11
|
+
import { readFileIfExists, writeFile } from "#filesystem/filesystem.ts"
|
|
12
|
+
import { isValidAlphaNumeric } from "#utils/validations.ts"
|
|
13
|
+
|
|
14
|
+
type DeployOptions = {
|
|
15
|
+
description?: string
|
|
16
|
+
force: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function deploymentsDeploy({ description, force }: DeployOptions) {
|
|
20
|
+
const { projectPath } = getCachedConfig()
|
|
21
|
+
|
|
22
|
+
const localHash = calculateTreeHash()
|
|
23
|
+
const remoteHash = await fetchSourceFileIfExists("build/hash")
|
|
24
|
+
const lastSeenRemoteHash = readFileIfExists(path.join(projectPath, ".nostocache/hash"))
|
|
25
|
+
|
|
26
|
+
if (!remoteHash) {
|
|
27
|
+
Logger.error("No files found in remote. Please run 'st build --push' first to push your build.")
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!force) {
|
|
32
|
+
if (localHash !== remoteHash) {
|
|
33
|
+
Logger.warn("Local files don't match remote.")
|
|
34
|
+
Logger.warn(`You may need to run ${chalk.cyan("st push")} first to push your changes.`)
|
|
35
|
+
const confirmed = await promptForConfirmation("Continue with deployment anyway?", "N")
|
|
36
|
+
if (!confirmed) {
|
|
37
|
+
Logger.info("Deployment cancelled by user.")
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (lastSeenRemoteHash !== remoteHash) {
|
|
43
|
+
Logger.warn("Remote files have changed since your last sync.")
|
|
44
|
+
const confirmed = await promptForConfirmation("Continue with deployment?", "N")
|
|
45
|
+
if (!confirmed) {
|
|
46
|
+
Logger.info("Deployment cancelled by user.")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const deploymentDescription = description || (await promptForInput("Enter a description for this deployment:"))
|
|
53
|
+
|
|
54
|
+
if (!isValidAlphaNumeric(deploymentDescription)) {
|
|
55
|
+
Logger.error("Description must be alphanumeric and between 1 and 200 characters.")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!force) {
|
|
60
|
+
const confirmed = await promptForConfirmation(
|
|
61
|
+
`You are about to create a deployment with description: ${chalk.cyan(`"${deploymentDescription}"`)}. Continue?`,
|
|
62
|
+
"N"
|
|
63
|
+
)
|
|
64
|
+
if (!confirmed) {
|
|
65
|
+
Logger.info("Deployment cancelled by user.")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Logger.info("Creating deployment from remote 'build' path...")
|
|
71
|
+
Logger.info(`Description: ${chalk.cyan(`"${deploymentDescription}"`)}`)
|
|
72
|
+
|
|
73
|
+
await withSpinner("Creating deployment...", async () => {
|
|
74
|
+
await deployWithRetry("build", deploymentDescription)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
Logger.success("Deployment created successfully!")
|
|
78
|
+
|
|
79
|
+
writeFile(path.join(projectPath, ".nostocache/hash"), remoteHash)
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
|
|
3
|
+
import { listDeployments } from "#api/deployments/listDeployments.ts"
|
|
4
|
+
import { Logger } from "#console/logger.ts"
|
|
5
|
+
import { withSpinner } from "#console/spinner.ts"
|
|
6
|
+
import { formatDate } from "#utils/formatDate.ts"
|
|
7
|
+
|
|
8
|
+
export async function deploymentsList() {
|
|
9
|
+
const deployments = await withSpinner("Collecting deployment data...", async () => {
|
|
10
|
+
return await listDeployments()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
if (!deployments || deployments.length === 0) {
|
|
14
|
+
Logger.info(chalk.yellow("No deployments found"))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Logger.info(chalk.bold(`Found ${deployments.length} deployment(s):`))
|
|
19
|
+
|
|
20
|
+
deployments.forEach((deployment, index) => {
|
|
21
|
+
const statusBadge = deployment.active ? chalk.green("Active") : chalk.red("Inactive")
|
|
22
|
+
const latestBadge = deployment.latest ? chalk.greenBright("[LATEST] ") : ""
|
|
23
|
+
const createdDate = formatDate(deployment.created)
|
|
24
|
+
const bulletin = deployment.active ? chalk.bgGreenBright(" ") : " "
|
|
25
|
+
Logger.info(`${bulletin} ${latestBadge}${chalk.blueBright(`ID: ${deployment.id}`)}`)
|
|
26
|
+
Logger.info(` ${chalk.bold("Status:")} ${statusBadge}`)
|
|
27
|
+
Logger.info(` ${chalk.bold("Created At:")} ${chalk.cyan(createdDate)}`)
|
|
28
|
+
|
|
29
|
+
if (deployment.userId) {
|
|
30
|
+
Logger.info(` ${chalk.bold("User:")} ${chalk.cyan(deployment.userId)}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (deployment.description) {
|
|
34
|
+
Logger.info(` ${chalk.bold("Description:")} ${deployment.description}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (index < deployments.length - 1) {
|
|
38
|
+
Logger.info("")
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { select } from "@inquirer/prompts"
|
|
2
|
+
import chalk from "chalk"
|
|
3
|
+
|
|
4
|
+
import { listDeployments } from "#api/deployments/listDeployments.ts"
|
|
5
|
+
import { updateDeployment } from "#api/deployments/updateDeployment.ts"
|
|
6
|
+
import { Logger } from "#console/logger.ts"
|
|
7
|
+
import { withSpinner } from "#console/spinner.ts"
|
|
8
|
+
import { promptForConfirmation } from "#console/userPrompt.ts"
|
|
9
|
+
import { formatDate } from "#utils/formatDate.ts"
|
|
10
|
+
|
|
11
|
+
type RedeployOptions = {
|
|
12
|
+
deploymentId?: string
|
|
13
|
+
force: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function deploymentsRedeploy({ deploymentId, force }: RedeployOptions) {
|
|
17
|
+
let selectedDeployment = null
|
|
18
|
+
let selectedDeploymentId
|
|
19
|
+
|
|
20
|
+
if (deploymentId) {
|
|
21
|
+
selectedDeploymentId = deploymentId
|
|
22
|
+
const deployments = await withSpinner("Collecting deployment data...", async () => {
|
|
23
|
+
return await listDeployments()
|
|
24
|
+
})
|
|
25
|
+
selectedDeployment = deployments.find(d => d.id === deploymentId) || null
|
|
26
|
+
|
|
27
|
+
if (!selectedDeployment) {
|
|
28
|
+
Logger.error(`Deployment with ID "${selectedDeploymentId}" not found.`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
const result = await selectDeploymentInteractively("Select a deployment to redeploy:")
|
|
33
|
+
|
|
34
|
+
if (!result) {
|
|
35
|
+
Logger.error("No deployment selected. Aborting.")
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
selectedDeploymentId = result.id
|
|
40
|
+
selectedDeployment = result.deployment
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Logger.info(`Selected deployment: ${chalk.cyan(selectedDeployment.id)}`)
|
|
44
|
+
if (selectedDeployment.description) {
|
|
45
|
+
Logger.info(`Description: ${selectedDeployment.description}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!force) {
|
|
49
|
+
const confirmed = await promptForConfirmation(
|
|
50
|
+
`Are you sure you want to redeploy version ${chalk.cyan(selectedDeploymentId)}?`,
|
|
51
|
+
"N"
|
|
52
|
+
)
|
|
53
|
+
if (!confirmed) {
|
|
54
|
+
Logger.info("Redeployment cancelled by user.")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await withSpinner(`Redeploying version ${chalk.cyan(selectedDeploymentId)}...`, async () => {
|
|
60
|
+
await updateDeployment(selectedDeploymentId)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
Logger.success("Redeployed successfully!")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function selectDeploymentInteractively(message: string) {
|
|
67
|
+
const deployments = await withSpinner("Collecting deployment data...", async () => {
|
|
68
|
+
return await listDeployments()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (!deployments || deployments.length === 0) {
|
|
72
|
+
Logger.error("No deployments found.")
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Logger.info(chalk.gray(`${chalk.bgGreenBright(" ")} = Currently active deployment\n`))
|
|
77
|
+
|
|
78
|
+
const choices = deployments.map(deployment => {
|
|
79
|
+
const createdDate = formatDate(deployment.created)
|
|
80
|
+
const statusBadge = deployment.active ? chalk.bgGreenBright(" ") : " "
|
|
81
|
+
const description = deployment.description ? ` - ${deployment.description}` : ""
|
|
82
|
+
const idColor = deployment.active ? chalk.green : chalk.blueBright
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
name: `${statusBadge} [${idColor(createdDate)}] ${idColor(deployment.id)}${description}`,
|
|
86
|
+
value: deployment.id,
|
|
87
|
+
description: deployment.description || "No description"
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const selectedId = await select({
|
|
92
|
+
message,
|
|
93
|
+
choices,
|
|
94
|
+
pageSize: 10
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (!selectedId) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const selectedDeployment = deployments.find(d => d.id === selectedId)
|
|
102
|
+
return selectedDeployment ? { id: selectedId, deployment: selectedDeployment } : null
|
|
103
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { rollbackDeployment } from "#api/deployments/rollbackDeployment.ts"
|
|
2
|
+
import { Logger } from "#console/logger.ts"
|
|
3
|
+
import { withSpinner } from "#console/spinner.ts"
|
|
4
|
+
import { promptForConfirmation } from "#console/userPrompt.ts"
|
|
5
|
+
|
|
6
|
+
type RollbackOptions = {
|
|
7
|
+
force: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function deploymentsRollback({ force }: RollbackOptions) {
|
|
11
|
+
if (!force) {
|
|
12
|
+
const confirmed = await promptForConfirmation(
|
|
13
|
+
`Are you sure you want to disable the currently active deployment?`,
|
|
14
|
+
"N"
|
|
15
|
+
)
|
|
16
|
+
if (!confirmed) {
|
|
17
|
+
Logger.info("Operation cancelled by user.")
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Logger.info("Disabling active deployment...")
|
|
23
|
+
|
|
24
|
+
await withSpinner("Disabling active deployment...", async () => {
|
|
25
|
+
await rollbackDeployment()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
Logger.success("Active deployment disabled successfully!")
|
|
29
|
+
}
|
|
@@ -8,19 +8,22 @@ import { getBuildContext } from "#filesystem/esbuild.ts"
|
|
|
8
8
|
import { isModernTemplateProject } from "#filesystem/legacyUtils.ts"
|
|
9
9
|
import { loadLibrary } from "#filesystem/loadLibrary.ts"
|
|
10
10
|
|
|
11
|
+
import { pushSearchTemplate } from "./push.ts"
|
|
12
|
+
|
|
11
13
|
type Props = {
|
|
12
14
|
watch: boolean
|
|
15
|
+
push?: boolean
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export async function buildSearchTemplate({ watch }: Props) {
|
|
18
|
+
export async function buildSearchTemplate({ watch, push = false }: Props) {
|
|
16
19
|
if (isModernTemplateProject()) {
|
|
17
|
-
await buildModernSearchTemplate({ watch })
|
|
20
|
+
await buildModernSearchTemplate({ watch, push })
|
|
18
21
|
} else {
|
|
19
|
-
await buildLegacySearchTemplate({ watch })
|
|
22
|
+
await buildLegacySearchTemplate({ watch, push })
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
async function buildModernSearchTemplate({ watch }: Props) {
|
|
26
|
+
async function buildModernSearchTemplate({ watch, push }: Props) {
|
|
24
27
|
const { onBuild, onBuildWatch } = getCachedSearchTemplatesConfig()
|
|
25
28
|
|
|
26
29
|
if (watch) {
|
|
@@ -30,9 +33,14 @@ async function buildModernSearchTemplate({ watch }: Props) {
|
|
|
30
33
|
} else {
|
|
31
34
|
await onBuild()
|
|
32
35
|
}
|
|
36
|
+
|
|
37
|
+
if (push && !watch) {
|
|
38
|
+
Logger.info("")
|
|
39
|
+
await pushSearchTemplate({ paths: ["build"], force: false })
|
|
40
|
+
}
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
async function buildLegacySearchTemplate({ watch }: Props) {
|
|
43
|
+
async function buildLegacySearchTemplate({ watch, push }: Props) {
|
|
36
44
|
const { projectPath } = getCachedConfig()
|
|
37
45
|
const libraryPath = path.resolve(projectPath, ".nostocache/library")
|
|
38
46
|
|
|
@@ -46,6 +54,11 @@ async function buildLegacySearchTemplate({ watch }: Props) {
|
|
|
46
54
|
if (!watch) {
|
|
47
55
|
await context.rebuild()
|
|
48
56
|
await context.dispose()
|
|
57
|
+
|
|
58
|
+
if (push) {
|
|
59
|
+
Logger.info("")
|
|
60
|
+
await pushSearchTemplate({ paths: ["build"], force: false })
|
|
61
|
+
}
|
|
49
62
|
return
|
|
50
63
|
}
|
|
51
64
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats a date as YYYY-MM-DD HH:mm in UTC
|
|
3
|
+
* @param date - Date to format (timestamp in milliseconds or Date object)
|
|
4
|
+
* @returns Formatted date string in UTC (e.g., "2025-10-10 17:33")
|
|
5
|
+
* @note Always uses UTC to ensure consistent display across timezones
|
|
6
|
+
*/
|
|
7
|
+
export function formatDate(date: number | Date): string {
|
|
8
|
+
const d = typeof date === "number" ? new Date(date) : date
|
|
9
|
+
|
|
10
|
+
const year = d.getUTCFullYear()
|
|
11
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
|
|
12
|
+
const day = String(d.getUTCDate()).padStart(2, "0")
|
|
13
|
+
const hours = String(d.getUTCHours()).padStart(2, "0")
|
|
14
|
+
const minutes = String(d.getUTCMinutes()).padStart(2, "0")
|
|
15
|
+
|
|
16
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`
|
|
17
|
+
}
|
package/test/commander.test.ts
CHANGED
|
@@ -147,7 +147,7 @@ describe("commander", () => {
|
|
|
147
147
|
|
|
148
148
|
it("should call the function", async () => {
|
|
149
149
|
await commander.run("nosto st build")
|
|
150
|
-
expect(buildSpy).toHaveBeenCalledWith({ watch: false })
|
|
150
|
+
expect(buildSpy).toHaveBeenCalledWith({ watch: false, push: false })
|
|
151
151
|
})
|
|
152
152
|
|
|
153
153
|
it("should rethrow errors", async () => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import ora from "ora"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
|
|
4
|
+
import { withSpinner } from "#console/spinner.ts"
|
|
5
|
+
import { setupMockConsole } from "#test/utils/mockConsole.ts"
|
|
6
|
+
|
|
7
|
+
const terminal = setupMockConsole()
|
|
8
|
+
|
|
9
|
+
describe("withSpinner", () => {
|
|
10
|
+
it("should start spinner, execute operation, and succeed", async () => {
|
|
11
|
+
const operation = vi.fn().mockResolvedValue("success")
|
|
12
|
+
|
|
13
|
+
const result = await withSpinner("Loading...", operation)
|
|
14
|
+
|
|
15
|
+
expect(ora).toHaveBeenCalledWith("Loading...")
|
|
16
|
+
expect(terminal.getSpinnerSpy("start")).toHaveBeenCalled()
|
|
17
|
+
expect(operation).toHaveBeenCalled()
|
|
18
|
+
expect(terminal.getSpinnerSpy("succeed")).toHaveBeenCalled()
|
|
19
|
+
expect(terminal.getSpinnerSpy("fail")).not.toHaveBeenCalled()
|
|
20
|
+
expect(result).toBe("success")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("should start spinner, handle error, and fail", async () => {
|
|
24
|
+
const error = new Error("API failed")
|
|
25
|
+
const operation = vi.fn().mockRejectedValue(error)
|
|
26
|
+
|
|
27
|
+
await expect(withSpinner("Loading...", operation)).rejects.toThrow("API failed")
|
|
28
|
+
|
|
29
|
+
expect(ora).toHaveBeenCalledWith("Loading...")
|
|
30
|
+
expect(terminal.getSpinnerSpy("start")).toHaveBeenCalled()
|
|
31
|
+
expect(operation).toHaveBeenCalled()
|
|
32
|
+
expect(terminal.getSpinnerSpy("fail")).toHaveBeenCalled()
|
|
33
|
+
expect(terminal.getSpinnerSpy("succeed")).not.toHaveBeenCalled()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("should return the operation result", async () => {
|
|
37
|
+
const expectedData = { id: 1, name: "test" }
|
|
38
|
+
const operation = vi.fn().mockResolvedValue(expectedData)
|
|
39
|
+
|
|
40
|
+
const result = await withSpinner("Fetching data...", operation)
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual(expectedData)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should handle synchronous errors", async () => {
|
|
46
|
+
const operation = vi.fn().mockImplementation(() => {
|
|
47
|
+
throw new Error("Sync error")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
await expect(withSpinner("Loading...", operation)).rejects.toThrow("Sync error")
|
|
51
|
+
|
|
52
|
+
expect(terminal.getSpinnerSpy("fail")).toHaveBeenCalled()
|
|
53
|
+
expect(terminal.getSpinnerSpy("succeed")).not.toHaveBeenCalled()
|
|
54
|
+
})
|
|
55
|
+
})
|
package/test/setup.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { beforeEach } from "vitest"
|
|
|
3
3
|
import { vi } from "vitest"
|
|
4
4
|
|
|
5
5
|
import { mockHttpServer } from "./utils/mockAuthServer.ts"
|
|
6
|
-
import { mockedConsoleIn, mockedConsoleOut } from "./utils/mockConsole.ts"
|
|
6
|
+
import { mockedConsoleIn, mockedConsoleOut, mockedSpinner } from "./utils/mockConsole.ts"
|
|
7
7
|
|
|
8
8
|
export const testVolume = Volume.fromJSON({}, "/")
|
|
9
9
|
|
|
@@ -37,3 +37,9 @@ vi.mock("open", () => {
|
|
|
37
37
|
vi.mock("node:http", () => ({
|
|
38
38
|
default: mockHttpServer
|
|
39
39
|
}))
|
|
40
|
+
|
|
41
|
+
vi.mock("ora", () => {
|
|
42
|
+
return {
|
|
43
|
+
default: vi.fn(() => mockedSpinner)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
@@ -28,9 +28,27 @@ export const mockedConsoleOut = {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export const mockedSpinner = {
|
|
32
|
+
start: vi.fn(function (this: typeof mockedSpinner) {
|
|
33
|
+
return this
|
|
34
|
+
}),
|
|
35
|
+
stop: vi.fn(function (this: typeof mockedSpinner) {
|
|
36
|
+
return this
|
|
37
|
+
}),
|
|
38
|
+
succeed: vi.fn(function (this: typeof mockedSpinner) {
|
|
39
|
+
return this
|
|
40
|
+
}),
|
|
41
|
+
fail: vi.fn(function (this: typeof mockedSpinner) {
|
|
42
|
+
return this
|
|
43
|
+
}),
|
|
44
|
+
text: ""
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
export function setupMockConsole() {
|
|
32
48
|
afterEach(() => {
|
|
33
49
|
mockedConsoleIn.recordedPrompts = []
|
|
50
|
+
mockedSpinner.text = ""
|
|
51
|
+
vi.clearAllMocks()
|
|
34
52
|
})
|
|
35
53
|
|
|
36
54
|
return {
|
|
@@ -58,10 +76,18 @@ export function setupMockConsole() {
|
|
|
58
76
|
merchantId: "",
|
|
59
77
|
isDryRun: false
|
|
60
78
|
}
|
|
79
|
+
mockedSpinner.start.mockClear()
|
|
80
|
+
mockedSpinner.stop.mockClear()
|
|
81
|
+
mockedSpinner.succeed.mockClear()
|
|
82
|
+
mockedSpinner.fail.mockClear()
|
|
83
|
+
mockedSpinner.text = ""
|
|
61
84
|
},
|
|
62
85
|
getSpy: (method: Exclude<keyof typeof mockedConsoleOut.Logger, "context">) => {
|
|
63
86
|
return mockedConsoleOut.Logger[method]
|
|
64
87
|
},
|
|
88
|
+
getSpinnerSpy: (method: keyof typeof mockedSpinner) => {
|
|
89
|
+
return mockedSpinner[method]
|
|
90
|
+
},
|
|
65
91
|
expect: {
|
|
66
92
|
user: {
|
|
67
93
|
toHaveBeenPromptedWith: (prompt: string) => {
|