Skip to content

Commit 84f3318

Browse files
authored
Merge pull request #140 from Phala-Network/feat/cli-update-check
feat(cli): update check + self update
2 parents e9f5dd8 + 9d348b7 commit 84f3318

File tree

16 files changed

+1109
-12
lines changed

16 files changed

+1109
-12
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
### feat
88

99
* **cli:** add api command for raw HTTP requests ([eaaecec](https://github.com/Phala-Network/phala-cloud/commit/eaaecec8a38421b29f62b6e9eb908215b837f987))
10+
* **cli:** add update check and self update ([e88f795](https://github.com/Phala-Network/phala-cloud/commit/e88f795ed26451e751577895543eb05d015583e9))
11+
1012
## [1.1.3](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.3-beta.1...cli-v1.1.3) (2026-01-16)
1113
## [1.1.3-beta.1](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.2...cli-v1.1.3-beta.1) (2026-01-16)
1214

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"open": "^10.0.0",
6060
"ora": "^6.3.1",
6161
"prompts": "^2.4.2",
62+
"semver": "^7.6.3",
6263
"zod": "^3.24.1"
6364
},
6465
"devDependencies": {

cli/src/commands/api/command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ ENVIRONMENT VARIABLES
3232
3333
PHALA_CLOUD_API_KEY Override the API key (useful for CI/CD)
3434
PHALA_CLOUD_API_PREFIX Override the API base URL`,
35-
stability: "stable",
35+
stability: "unstable",
3636
arguments: [
3737
{
3838
name: "endpoint",

cli/src/commands/link/command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const jsonOption = {
1313
export const linkCommandMeta: CommandMeta = {
1414
name: "link",
1515
description: "Link a local directory to a CVM",
16-
stability: "stable",
16+
stability: "unstable",
1717
arguments: [
1818
{
1919
name: "cvm-id",

cli/src/commands/self/command.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { CommandGroup } from "@/src/core/types";
2+
3+
export const selfGroup: CommandGroup = {
4+
path: ["self"],
5+
meta: {
6+
name: "self",
7+
description: "Self management commands",
8+
stability: "unstable",
9+
},
10+
};

cli/src/commands/self/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { selfGroup } from "./command";
2+
import { selfUpdateCommand } from "./update";
3+
4+
export const selfCommands = {
5+
group: selfGroup,
6+
commands: [selfUpdateCommand],
7+
};
8+
9+
export default selfCommands;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { z } from "zod";
2+
import type { CommandMeta } from "@/src/core/types";
3+
4+
export const jsonOption = {
5+
name: "json",
6+
shorthand: "j",
7+
description: "Output in JSON format",
8+
type: "boolean" as const,
9+
target: "json",
10+
negatedName: "no-json",
11+
};
12+
13+
export const yesOption = {
14+
name: "yes",
15+
shorthand: "y",
16+
description: "Skip confirmation prompt",
17+
type: "boolean" as const,
18+
target: "yes",
19+
};
20+
21+
export const dryRunOption = {
22+
name: "dry-run",
23+
description: "Print the update command without executing it",
24+
type: "boolean" as const,
25+
target: "dryRun",
26+
};
27+
28+
export const packageManagerOption = {
29+
name: "package-manager",
30+
description: "Override package manager (npm|pnpm|yarn|bun)",
31+
type: "string" as const,
32+
target: "packageManager",
33+
aliases: ["pm"],
34+
argumentName: "name",
35+
};
36+
37+
export const channelOption = {
38+
name: "channel",
39+
description: "Release channel/dist-tag (e.g. latest, beta, next)",
40+
type: "string" as const,
41+
target: "channel",
42+
argumentName: "tag",
43+
};
44+
45+
export const selfUpdateCommandMeta: CommandMeta = {
46+
name: "update",
47+
description: "Update the Phala CLI",
48+
stability: "unstable",
49+
options: [
50+
jsonOption,
51+
yesOption,
52+
dryRunOption,
53+
packageManagerOption,
54+
channelOption,
55+
],
56+
examples: [
57+
{ name: "Update CLI", value: "phala self update" },
58+
{ name: "Dry run (print command)", value: "phala self update --dry-run" },
59+
{ name: "Use beta channel", value: "phala self update --channel beta" },
60+
],
61+
};
62+
63+
export const selfUpdateCommandSchema = z.object({
64+
json: z.boolean().default(false),
65+
yes: z.boolean().default(false),
66+
dryRun: z.boolean().default(false),
67+
packageManager: z.enum(["npm", "pnpm", "yarn", "bun"]).optional(),
68+
channel: z
69+
.string()
70+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/)
71+
.optional(),
72+
});
73+
74+
export type SelfUpdateCommandInput = z.infer<typeof selfUpdateCommandSchema>;
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import prompts from "prompts";
2+
import { execa } from "execa";
3+
import semver from "semver";
4+
import { defineCommand } from "@/src/core/define-command";
5+
import type { CommandContext } from "@/src/core/types";
6+
import { logger, setJsonMode } from "@/src/utils/logger";
7+
import { getConfigValue } from "@/src/utils/config";
8+
import {
9+
detectPackageManager,
10+
detectRuntimeFromProcess,
11+
formatGlobalInstallCommand,
12+
getGlobalInstallArgs,
13+
type PackageManagerName,
14+
type RuntimeName,
15+
} from "@/src/core/package-manager";
16+
import {
17+
selfUpdateCommandMeta,
18+
selfUpdateCommandSchema,
19+
type SelfUpdateCommandInput,
20+
} from "./command";
21+
22+
function isNonEmptyString(value: unknown): value is string {
23+
return typeof value === "string" && value.trim().length > 0;
24+
}
25+
26+
function getErrorCode(error: unknown): string | undefined {
27+
if (typeof error !== "object" || error === null) {
28+
return undefined;
29+
}
30+
if (!("code" in error)) {
31+
return undefined;
32+
}
33+
const code = (error as Record<string, unknown>).code;
34+
return typeof code === "string" ? code : undefined;
35+
}
36+
37+
function getCurrentChannel(currentVersion: string): string | undefined {
38+
const parsed = semver.parse(currentVersion);
39+
const pre = parsed?.prerelease?.[0];
40+
return typeof pre === "string" && pre.length > 0 ? pre : undefined;
41+
}
42+
43+
function getSelfUpdateHints(
44+
env: NodeJS.ProcessEnv,
45+
packageManager: PackageManagerName,
46+
command: string,
47+
error: unknown,
48+
): string[] {
49+
const hints: string[] = [];
50+
51+
const message =
52+
error instanceof Error && typeof error.message === "string"
53+
? error.message
54+
: String(error);
55+
56+
const errorCode = getErrorCode(error);
57+
if (
58+
/permission denied|eacces|not permitted/i.test(message) ||
59+
(error instanceof Error &&
60+
(errorCode === "EACCES" || errorCode === "EPERM"))
61+
) {
62+
hints.push(
63+
`Permission error while running "${command}". Ensure your global install prefix is writable (avoid sudo if possible).`,
64+
);
65+
}
66+
67+
if (/command not found|not recognized as an internal|ENOENT/i.test(message)) {
68+
hints.push(
69+
`Package manager "${packageManager}" not found. Install it or rerun with --package-manager npm|pnpm|yarn|bun.`,
70+
);
71+
}
72+
73+
const nvmDir = env.NVM_DIR;
74+
if (packageManager === "npm" && isNonEmptyString(nvmDir)) {
75+
hints.push(
76+
`Detected nvm (NVM_DIR=${nvmDir}). Make sure the intended Node version is active (e.g. "nvm use <version>") and your npm global bin is on PATH.`,
77+
);
78+
}
79+
80+
const fnmDir = env.FNM_DIR;
81+
if (packageManager === "npm" && isNonEmptyString(fnmDir)) {
82+
hints.push(
83+
`Detected fnm (FNM_DIR=${fnmDir}). Ensure your shell has fnm env loaded (e.g. eval \"$(fnm env)\") so the correct node/npm are on PATH.`,
84+
);
85+
}
86+
87+
return hints;
88+
}
89+
90+
function resolveChannel(
91+
inputChannel: string | undefined,
92+
currentVersion: string,
93+
env: NodeJS.ProcessEnv,
94+
): string {
95+
if (isNonEmptyString(inputChannel)) return inputChannel.trim();
96+
if (isNonEmptyString(env.PHALA_UPDATE_CHANNEL))
97+
return env.PHALA_UPDATE_CHANNEL;
98+
const configChannel = getConfigValue("updateCheckChannel");
99+
if (isNonEmptyString(configChannel)) return configChannel;
100+
return getCurrentChannel(currentVersion) ?? "latest";
101+
}
102+
103+
async function runSelfUpdate(
104+
input: SelfUpdateCommandInput,
105+
context: CommandContext,
106+
): Promise<number> {
107+
setJsonMode(input.json);
108+
109+
const packageName = context.cli?.packageName ?? "phala";
110+
const runtime: RuntimeName =
111+
(context.cli?.runtime as RuntimeName | undefined) ??
112+
detectRuntimeFromProcess();
113+
const packageManager: PackageManagerName =
114+
input.packageManager ?? detectPackageManager(context.env, runtime);
115+
const currentVersion = context.cli?.packageVersion ?? "0.0.0";
116+
const channel = resolveChannel(input.channel, currentVersion, context.env);
117+
118+
const spec =
119+
channel === "latest"
120+
? `${packageName}@latest`
121+
: `${packageName}@${channel}`;
122+
const commandString = formatGlobalInstallCommand(packageManager, spec);
123+
const { command, args } = getGlobalInstallArgs(packageManager, spec);
124+
125+
if (input.json && input.dryRun) {
126+
context.success({ command: commandString, dryRun: true });
127+
return 0;
128+
}
129+
130+
if (input.dryRun) {
131+
context.stdout.write(`${commandString}\n`);
132+
return 0;
133+
}
134+
135+
const canPrompt = context.stderr.isTTY === true && input.json !== true;
136+
if (!input.yes && !canPrompt) {
137+
const message =
138+
"Non-interactive session detected. Re-run with --yes to apply the update, or use --dry-run to print the command.\n";
139+
if (input.json) {
140+
context.success({
141+
command: commandString,
142+
ran: false,
143+
reason: "nonInteractive",
144+
});
145+
return 0;
146+
}
147+
context.stderr.write(message);
148+
return 1;
149+
}
150+
151+
const shouldRun =
152+
input.yes ||
153+
(await prompts({
154+
type: "confirm",
155+
name: "ok",
156+
message: `Run "${commandString}"?`,
157+
initial: true,
158+
}).then((r) => r.ok === true));
159+
160+
if (!shouldRun) {
161+
if (input.json) {
162+
context.success({ command: commandString, ran: false });
163+
}
164+
return 0;
165+
}
166+
167+
try {
168+
await execa(command, args, { stdio: "inherit" });
169+
if (input.json) {
170+
context.success({ command: commandString, ran: true });
171+
return 0;
172+
}
173+
logger.success("Update completed");
174+
return 0;
175+
} catch (error) {
176+
const hints = getSelfUpdateHints(
177+
context.env,
178+
packageManager,
179+
commandString,
180+
error,
181+
);
182+
logger.logDetailedError(error);
183+
context.fail("Self update failed", {
184+
command: commandString,
185+
hints: hints.length > 0 ? hints : undefined,
186+
});
187+
if (!input.json && hints.length > 0) {
188+
for (const hint of hints) {
189+
logger.info(hint);
190+
}
191+
}
192+
return 1;
193+
}
194+
}
195+
196+
export const selfUpdateCommand = defineCommand({
197+
path: ["self", "update"],
198+
meta: selfUpdateCommandMeta,
199+
schema: selfUpdateCommandSchema,
200+
handler: runSelfUpdate,
201+
});

0 commit comments

Comments
 (0)