@pensar/apex 0.0.70 → 0.0.71

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/build/index.js CHANGED
@@ -135337,7 +135337,7 @@ import fs2 from "fs/promises";
135337
135337
  // package.json
135338
135338
  var package_default2 = {
135339
135339
  name: "@pensar/apex",
135340
- version: "0.0.70",
135340
+ version: "0.0.71",
135341
135341
  description: "AI-powered penetration testing CLI tool with terminal UI",
135342
135342
  module: "src/tui/index.tsx",
135343
135343
  main: "build/index.js",
@@ -135352,6 +135352,7 @@ var package_default2 = {
135352
135352
  files: [
135353
135353
  "build",
135354
135354
  "bin",
135355
+ "src/core/installation",
135355
135356
  "pensar.svg",
135356
135357
  "LICENSE"
135357
135358
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pensar/apex",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "description": "AI-powered penetration testing CLI tool with terminal UI",
5
5
  "module": "src/tui/index.tsx",
6
6
  "main": "build/index.js",
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "build",
17
17
  "bin",
18
+ "src/core/installation",
18
19
  "pensar.svg",
19
20
  "LICENSE"
20
21
  ],
@@ -0,0 +1,211 @@
1
+ import { spawnSync } from "child_process";
2
+ import packageJson from "../../../package.json";
3
+
4
+ export type InstallMethod = "npm" | "homebrew" | "binary";
5
+
6
+ export interface UpgradeResult {
7
+ success: boolean;
8
+ message: string;
9
+ fromVersion: string;
10
+ toVersion: string;
11
+ }
12
+
13
+ export async function resolveVersion(): Promise<string> {
14
+ if (process.env["APEX_VERSION"]) return process.env["APEX_VERSION"];
15
+ return getLatestVersion();
16
+ }
17
+
18
+ export function getCurrentVersion(): string {
19
+ return packageJson.version;
20
+ }
21
+
22
+ /**
23
+ * Returns true if `latest` is strictly greater than `current` by semver.
24
+ * Only compares major.minor.patch; pre-release suffixes are ignored.
25
+ */
26
+ export function isNewerVersion(current: string, latest: string): boolean {
27
+ const parse = (v: string) => v.split(".").map((n) => parseInt(n, 10) || 0);
28
+ const c = parse(current);
29
+ const l = parse(latest);
30
+ for (let i = 0; i < Math.max(c.length, l.length); i++) {
31
+ const cv = c[i] ?? 0;
32
+ const lv = l[i] ?? 0;
33
+ if (lv > cv) return true;
34
+ if (lv < cv) return false;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ export async function getLatestVersion(): Promise<string> {
40
+ const res = await fetch("https://registry.npmjs.org/@pensar/apex/latest");
41
+ if (!res.ok)
42
+ throw new Error(`Failed to fetch latest version: ${res.statusText}`);
43
+ const data = (await res.json()) as Record<string, unknown>;
44
+ return String(data.version);
45
+ }
46
+
47
+ export function detectInstallMethod(): InstallMethod {
48
+ const execPath = process.execPath;
49
+ const argv1 = process.argv[1] ?? "";
50
+
51
+ if (
52
+ execPath.includes("homebrew") ||
53
+ execPath.includes("Cellar") ||
54
+ execPath.includes("linuxbrew") ||
55
+ argv1.includes("homebrew") ||
56
+ argv1.includes("Cellar")
57
+ ) {
58
+ return "homebrew";
59
+ }
60
+
61
+ if (
62
+ argv1.includes("node_modules") ||
63
+ argv1.includes(".npm") ||
64
+ argv1.includes("npx")
65
+ ) {
66
+ return "npm";
67
+ }
68
+
69
+ const npmCheck = spawnSync(
70
+ "npm",
71
+ ["list", "-g", "@pensar/apex", "--depth=0"],
72
+ {
73
+ encoding: "utf-8",
74
+ timeout: 10000,
75
+ },
76
+ );
77
+ if (npmCheck.status === 0 && npmCheck.stdout?.includes("@pensar/apex")) {
78
+ return "npm";
79
+ }
80
+
81
+ return "binary";
82
+ }
83
+
84
+ function getUpgradeCommand(method: InstallMethod): {
85
+ cmd: string;
86
+ args: string[];
87
+ } {
88
+ switch (method) {
89
+ case "npm":
90
+ return { cmd: "npm", args: ["install", "-g", "@pensar/apex@latest"] };
91
+ case "homebrew":
92
+ return { cmd: "brew", args: ["upgrade", "pensarai/tap/apex"] };
93
+ case "binary":
94
+ return {
95
+ cmd: "bash",
96
+ args: ["-c", "curl -fsSL https://pensarai.com/install.sh | bash"],
97
+ };
98
+ }
99
+ }
100
+
101
+ export function getUpgradeCommandString(method: InstallMethod): string {
102
+ const { cmd, args } = getUpgradeCommand(method);
103
+ return `${cmd} ${args.join(" ")}`;
104
+ }
105
+
106
+ export interface CheckUpdateResult {
107
+ updateAvailable: boolean;
108
+ currentVersion: string;
109
+ latestVersion: string;
110
+ }
111
+
112
+ export async function checkForUpdate(): Promise<CheckUpdateResult> {
113
+ const currentVersion = getCurrentVersion();
114
+ let latestVersion: string;
115
+
116
+ try {
117
+ latestVersion = await getLatestVersion();
118
+ } catch {
119
+ return {
120
+ updateAvailable: false,
121
+ currentVersion,
122
+ latestVersion: currentVersion,
123
+ };
124
+ }
125
+
126
+ return {
127
+ updateAvailable: isNewerVersion(currentVersion, latestVersion),
128
+ currentVersion,
129
+ latestVersion,
130
+ };
131
+ }
132
+
133
+ export function runUpgrade(
134
+ method: InstallMethod,
135
+ options?: { interactive?: boolean },
136
+ ): UpgradeResult {
137
+ const currentVersion = getCurrentVersion();
138
+ const { cmd, args } = getUpgradeCommand(method);
139
+ const interactive = options?.interactive ?? false;
140
+
141
+ const result = spawnSync(cmd, args, {
142
+ encoding: "utf-8",
143
+ timeout: 120000,
144
+ stdio: interactive ? "inherit" : "pipe",
145
+ });
146
+
147
+ if (result.status !== 0) {
148
+ const stderr =
149
+ (typeof result.stderr === "string" ? result.stderr.trim() : "") ||
150
+ "Unknown error";
151
+ return {
152
+ success: false,
153
+ message: `Upgrade failed: ${stderr}`,
154
+ fromVersion: currentVersion,
155
+ toVersion: currentVersion,
156
+ };
157
+ }
158
+
159
+ return {
160
+ success: true,
161
+ message:
162
+ "Upgrade successful. Please restart pensar to use the new version.",
163
+ fromVersion: currentVersion,
164
+ toVersion: "latest",
165
+ };
166
+ }
167
+
168
+ export async function upgrade(options?: {
169
+ interactive?: boolean;
170
+ }): Promise<UpgradeResult> {
171
+ const currentVersion = getCurrentVersion();
172
+ let latestVersion: string;
173
+
174
+ try {
175
+ latestVersion = await getLatestVersion();
176
+ } catch {
177
+ return {
178
+ success: false,
179
+ message:
180
+ "Failed to check for updates. Please check your internet connection.",
181
+ fromVersion: currentVersion,
182
+ toVersion: currentVersion,
183
+ };
184
+ }
185
+
186
+ if (!isNewerVersion(currentVersion, latestVersion)) {
187
+ return {
188
+ success: true,
189
+ message: `Already on the latest version (v${currentVersion}).`,
190
+ fromVersion: currentVersion,
191
+ toVersion: latestVersion,
192
+ };
193
+ }
194
+
195
+ const method = detectInstallMethod();
196
+ const result = runUpgrade(method, options);
197
+
198
+ if (result.success) {
199
+ return {
200
+ ...result,
201
+ toVersion: latestVersion,
202
+ message: `Upgraded from v${currentVersion} to v${latestVersion}. Please restart pensar.`,
203
+ };
204
+ }
205
+
206
+ return {
207
+ ...result,
208
+ toVersion: latestVersion,
209
+ message: `${result.message}\n\nYou can upgrade manually by running:\n ${getUpgradeCommandString(method)}`,
210
+ };
211
+ }
@@ -0,0 +1,422 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import {
3
+ getCurrentVersion,
4
+ getLatestVersion,
5
+ resolveVersion,
6
+ isNewerVersion,
7
+ detectInstallMethod,
8
+ getUpgradeCommandString,
9
+ checkForUpdate,
10
+ upgrade,
11
+ } from "./index";
12
+ import packageJson from "../../../package.json";
13
+
14
+ vi.mock("child_process", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("child_process")>();
16
+ return { ...actual, spawnSync: vi.fn(actual.spawnSync) };
17
+ });
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // getCurrentVersion
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe("getCurrentVersion", () => {
24
+ it("returns the version from package.json", () => {
25
+ expect(getCurrentVersion()).toBe(packageJson.version);
26
+ });
27
+
28
+ it("returns a valid semver-like string", () => {
29
+ const version = getCurrentVersion();
30
+ expect(version).toMatch(/^\d+\.\d+\.\d+/);
31
+ });
32
+ });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // resolveVersion
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("resolveVersion", () => {
39
+ const originalEnv = process.env["APEX_VERSION"];
40
+
41
+ afterEach(() => {
42
+ if (originalEnv !== undefined) {
43
+ process.env["APEX_VERSION"] = originalEnv;
44
+ } else {
45
+ delete process.env["APEX_VERSION"];
46
+ }
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it("returns APEX_VERSION env var when set", async () => {
51
+ process.env["APEX_VERSION"] = "1.2.3-custom";
52
+ const version = await resolveVersion();
53
+ expect(version).toBe("1.2.3-custom");
54
+ });
55
+
56
+ it("delegates to getLatestVersion when env var is not set", async () => {
57
+ delete process.env["APEX_VERSION"];
58
+ const mockFetch = vi
59
+ .spyOn(globalThis, "fetch")
60
+ .mockResolvedValue(
61
+ new Response(JSON.stringify({ version: "9.9.9" }), { status: 200 }),
62
+ );
63
+ const version = await resolveVersion();
64
+ expect(version).toBe("9.9.9");
65
+ expect(mockFetch).toHaveBeenCalledWith(
66
+ "https://registry.npmjs.org/@pensar/apex/latest",
67
+ );
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // getLatestVersion
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("getLatestVersion", () => {
76
+ afterEach(() => vi.restoreAllMocks());
77
+
78
+ it("returns the latest version from npm registry", async () => {
79
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
80
+ new Response(JSON.stringify({ version: "1.0.0" }), { status: 200 }),
81
+ );
82
+ const version = await getLatestVersion();
83
+ expect(version).toBe("1.0.0");
84
+ });
85
+
86
+ it("throws on non-ok response", async () => {
87
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
88
+ new Response("not found", { status: 404, statusText: "Not Found" }),
89
+ );
90
+ await expect(getLatestVersion()).rejects.toThrow("Not Found");
91
+ });
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // isNewerVersion
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("isNewerVersion", () => {
99
+ it("returns true when latest has higher patch", () => {
100
+ expect(isNewerVersion("0.0.66", "0.0.67")).toBe(true);
101
+ });
102
+
103
+ it("returns true when latest has higher minor", () => {
104
+ expect(isNewerVersion("0.1.0", "0.2.0")).toBe(true);
105
+ });
106
+
107
+ it("returns true when latest has higher major", () => {
108
+ expect(isNewerVersion("1.0.0", "2.0.0")).toBe(true);
109
+ });
110
+
111
+ it("returns false when versions are equal", () => {
112
+ expect(isNewerVersion("0.0.67", "0.0.67")).toBe(false);
113
+ });
114
+
115
+ it("returns false when current is newer than latest", () => {
116
+ expect(isNewerVersion("0.0.67", "0.0.66")).toBe(false);
117
+ });
118
+
119
+ it("returns false when current major is higher", () => {
120
+ expect(isNewerVersion("2.0.0", "1.9.9")).toBe(false);
121
+ });
122
+
123
+ it("handles versions with different segment counts", () => {
124
+ expect(isNewerVersion("1.0", "1.0.1")).toBe(true);
125
+ expect(isNewerVersion("1.0.1", "1.0")).toBe(false);
126
+ });
127
+ });
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // detectInstallMethod
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe("detectInstallMethod", () => {
134
+ const origExecPath = process.execPath;
135
+ const origArgv = [...process.argv];
136
+
137
+ afterEach(() => {
138
+ Object.defineProperty(process, "execPath", {
139
+ value: origExecPath,
140
+ writable: true,
141
+ });
142
+ process.argv = [...origArgv];
143
+ vi.restoreAllMocks();
144
+ });
145
+
146
+ it("detects homebrew from execPath", () => {
147
+ Object.defineProperty(process, "execPath", {
148
+ value: "/opt/homebrew/bin/bun",
149
+ writable: true,
150
+ });
151
+ expect(detectInstallMethod()).toBe("homebrew");
152
+ });
153
+
154
+ it("detects homebrew from Cellar in execPath", () => {
155
+ Object.defineProperty(process, "execPath", {
156
+ value: "/opt/homebrew/Cellar/apex/0.1.0/bin/pensar",
157
+ writable: true,
158
+ });
159
+ expect(detectInstallMethod()).toBe("homebrew");
160
+ });
161
+
162
+ it("detects npm from node_modules in argv[1]", () => {
163
+ Object.defineProperty(process, "execPath", {
164
+ value: "/usr/local/bin/node",
165
+ writable: true,
166
+ });
167
+ process.argv[1] = "/usr/local/lib/node_modules/@pensar/apex/bin/pensar.js";
168
+ expect(detectInstallMethod()).toBe("npm");
169
+ });
170
+
171
+ it("detects npm from npx in argv[1]", () => {
172
+ Object.defineProperty(process, "execPath", {
173
+ value: "/usr/local/bin/node",
174
+ writable: true,
175
+ });
176
+ process.argv[1] = "/home/user/.npm/_npx/abc/node_modules/.bin/pensar";
177
+ expect(detectInstallMethod()).toBe("npm");
178
+ });
179
+
180
+ it("detects npm via spawnSync fallback when path heuristics miss", async () => {
181
+ Object.defineProperty(process, "execPath", {
182
+ value: "/usr/local/bin/node",
183
+ writable: true,
184
+ });
185
+ process.argv[1] = "/usr/local/bin/pensar";
186
+
187
+ const { spawnSync } = await import("child_process");
188
+ const mockedSpawnSync = vi.mocked(spawnSync);
189
+ mockedSpawnSync.mockReturnValue({
190
+ status: 0,
191
+ stdout: "└── @pensar/apex@0.1.0",
192
+ stderr: "",
193
+ pid: 0,
194
+ output: [],
195
+ signal: null,
196
+ });
197
+
198
+ expect(detectInstallMethod()).toBe("npm");
199
+ expect(mockedSpawnSync).toHaveBeenCalledWith(
200
+ "npm",
201
+ ["list", "-g", "@pensar/apex", "--depth=0"],
202
+ expect.objectContaining({ encoding: "utf-8" }),
203
+ );
204
+ });
205
+
206
+ it("returns binary when all heuristics fail", async () => {
207
+ Object.defineProperty(process, "execPath", {
208
+ value: "/usr/local/bin/node",
209
+ writable: true,
210
+ });
211
+ process.argv[1] = "/usr/local/bin/pensar";
212
+
213
+ const { spawnSync } = await import("child_process");
214
+ const mockedSpawnSync = vi.mocked(spawnSync);
215
+ mockedSpawnSync.mockReturnValue({
216
+ status: 1,
217
+ stdout: "",
218
+ stderr: "",
219
+ pid: 0,
220
+ output: [],
221
+ signal: null,
222
+ });
223
+
224
+ expect(detectInstallMethod()).toBe("binary");
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // getUpgradeCommandString
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe("getUpgradeCommandString", () => {
233
+ it("returns npm install command for npm method", () => {
234
+ expect(getUpgradeCommandString("npm")).toBe(
235
+ "npm install -g @pensar/apex@latest",
236
+ );
237
+ });
238
+
239
+ it("returns brew upgrade command for homebrew method", () => {
240
+ expect(getUpgradeCommandString("homebrew")).toBe(
241
+ "brew upgrade pensarai/tap/apex",
242
+ );
243
+ });
244
+
245
+ it("returns curl command for binary method", () => {
246
+ const cmd = getUpgradeCommandString("binary");
247
+ expect(cmd).toContain("curl");
248
+ expect(cmd).toContain("pensarai.com/install.sh");
249
+ });
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // checkForUpdate
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe("checkForUpdate", () => {
257
+ afterEach(() => vi.restoreAllMocks());
258
+
259
+ it("reports no update when versions match", async () => {
260
+ const current = getCurrentVersion();
261
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
262
+ new Response(JSON.stringify({ version: current }), { status: 200 }),
263
+ );
264
+
265
+ const result = await checkForUpdate();
266
+ expect(result.updateAvailable).toBe(false);
267
+ expect(result.currentVersion).toBe(current);
268
+ expect(result.latestVersion).toBe(current);
269
+ });
270
+
271
+ it("reports update available when latest is greater", async () => {
272
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
273
+ new Response(JSON.stringify({ version: "99.99.99" }), { status: 200 }),
274
+ );
275
+
276
+ const result = await checkForUpdate();
277
+ expect(result.updateAvailable).toBe(true);
278
+ expect(result.currentVersion).toBe(getCurrentVersion());
279
+ expect(result.latestVersion).toBe("99.99.99");
280
+ });
281
+
282
+ it("reports no update when current is newer than latest", async () => {
283
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
284
+ new Response(JSON.stringify({ version: "0.0.1" }), { status: 200 }),
285
+ );
286
+
287
+ const result = await checkForUpdate();
288
+ expect(result.updateAvailable).toBe(false);
289
+ expect(result.latestVersion).toBe("0.0.1");
290
+ });
291
+
292
+ it("returns no update on network failure", async () => {
293
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
294
+
295
+ const result = await checkForUpdate();
296
+ expect(result.updateAvailable).toBe(false);
297
+ });
298
+ });
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // upgrade (integration of version check + upgrade logic)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ describe("upgrade", () => {
305
+ afterEach(() => vi.restoreAllMocks());
306
+
307
+ it("reports already up to date when versions match", async () => {
308
+ const current = getCurrentVersion();
309
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
310
+ new Response(JSON.stringify({ version: current }), { status: 200 }),
311
+ );
312
+
313
+ const result = await upgrade();
314
+ expect(result.success).toBe(true);
315
+ expect(result.fromVersion).toBe(current);
316
+ expect(result.toVersion).toBe(current);
317
+ expect(result.message).toContain("Already on the latest version");
318
+ });
319
+
320
+ it("returns failure when version check fails", async () => {
321
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
322
+
323
+ const result = await upgrade();
324
+ expect(result.success).toBe(false);
325
+ expect(result.message).toContain("Failed to check for updates");
326
+ });
327
+
328
+ it("attempts upgrade when a newer version is available", async () => {
329
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
330
+ new Response(JSON.stringify({ version: "99.99.99" }), { status: 200 }),
331
+ );
332
+
333
+ const { spawnSync } = await import("child_process");
334
+ const mockedSpawnSync = vi.mocked(spawnSync);
335
+ mockedSpawnSync.mockReturnValue({
336
+ status: 0,
337
+ stdout: "upgraded successfully",
338
+ stderr: "",
339
+ pid: 0,
340
+ output: [],
341
+ signal: null,
342
+ });
343
+
344
+ const result = await upgrade();
345
+ expect(result.success).toBe(true);
346
+ expect(result.fromVersion).toBe(getCurrentVersion());
347
+ expect(result.toVersion).toBe("99.99.99");
348
+ expect(result.message).toContain("Upgraded from");
349
+ });
350
+
351
+ it("uses stdio inherit when interactive is true", async () => {
352
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
353
+ new Response(JSON.stringify({ version: "99.99.99" }), { status: 200 }),
354
+ );
355
+
356
+ const { spawnSync } = await import("child_process");
357
+ const mockedSpawnSync = vi.mocked(spawnSync);
358
+ mockedSpawnSync.mockReturnValue({
359
+ status: 0,
360
+ stdout: "",
361
+ stderr: "",
362
+ pid: 0,
363
+ output: [],
364
+ signal: null,
365
+ });
366
+
367
+ await upgrade({ interactive: true });
368
+
369
+ expect(mockedSpawnSync).toHaveBeenCalledWith(
370
+ expect.any(String),
371
+ expect.any(Array),
372
+ expect.objectContaining({ stdio: "inherit" }),
373
+ );
374
+ });
375
+
376
+ it("uses stdio pipe when interactive is false", async () => {
377
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
378
+ new Response(JSON.stringify({ version: "99.99.99" }), { status: 200 }),
379
+ );
380
+
381
+ const { spawnSync } = await import("child_process");
382
+ const mockedSpawnSync = vi.mocked(spawnSync);
383
+ mockedSpawnSync.mockReturnValue({
384
+ status: 0,
385
+ stdout: "",
386
+ stderr: "",
387
+ pid: 0,
388
+ output: [],
389
+ signal: null,
390
+ });
391
+
392
+ await upgrade({ interactive: false });
393
+
394
+ expect(mockedSpawnSync).toHaveBeenCalledWith(
395
+ expect.any(String),
396
+ expect.any(Array),
397
+ expect.objectContaining({ stdio: "pipe" }),
398
+ );
399
+ });
400
+
401
+ it("includes manual command on upgrade failure", async () => {
402
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
403
+ new Response(JSON.stringify({ version: "99.99.99" }), { status: 200 }),
404
+ );
405
+
406
+ const { spawnSync } = await import("child_process");
407
+ const mockedSpawnSync = vi.mocked(spawnSync);
408
+ mockedSpawnSync.mockReturnValue({
409
+ status: 1,
410
+ stdout: "",
411
+ stderr: "permission denied",
412
+ pid: 0,
413
+ output: [],
414
+ signal: null,
415
+ });
416
+
417
+ const result = await upgrade();
418
+ expect(result.success).toBe(false);
419
+ expect(result.message).toContain("permission denied");
420
+ expect(result.message).toContain("You can upgrade manually");
421
+ });
422
+ });