Skip to content

Commit 1c31df6

Browse files
authored
Merge pull request #28 from increments/add-package-update-notice
Add package update notice
2 parents 42f74f0 + 0d3b49e commit 1c31df6

15 files changed

+361
-9
lines changed

.env.sample

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ QIITA_DOMAIN='qiita.com'
22
QIITA_TOKEN='<Qiita トークン>'
33
QIITA_CLI_ITEMS_ROOT="./tmp"
44
XDG_CONFIG_HOME="./tmp"
5+
XDG_CACHE_HOME="./tmp/.cache"

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
},
7171
"dependencies": {
7272
"arg": "^5.0.2",
73+
"boxen": "^7.1.1",
74+
"chalk": "^5.3.0",
7375
"chokidar": "^3.5.3",
7476
"debug": "^4.3.4",
7577
"dotenv": "^16.0.3",

src/commands/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
import { packageUpdateNotice } from "../lib/package-update-notice";
12
import { help, helpText } from "./help";
23
import { init } from "./init";
34
import { login } from "./login";
45
import { newArticles } from "./newArticles";
56
import { preview } from "./preview";
67
import { publish } from "./publish";
7-
import { version } from "./version";
88
import { pull } from "./pull";
9+
import { version } from "./version";
910

10-
export const exec = (commandName: string, commandArgs: string[]) => {
11+
export const exec = async (commandName: string, commandArgs: string[]) => {
1112
const commands = {
1213
init,
1314
login,
@@ -32,5 +33,10 @@ export const exec = (commandName: string, commandArgs: string[]) => {
3233
process.exit(1);
3334
}
3435

36+
const updateMessage = await packageUpdateNotice();
37+
if (updateMessage) {
38+
console.log(updateMessage);
39+
}
40+
3541
commands[commandName](commandArgs);
3642
};

src/lib/config.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import process from "node:process";
12
import { config } from "./config";
23

4+
jest.mock("node:process");
5+
36
const initMockFs = () => {
47
let files: { [path: string]: string } = {};
58

@@ -175,6 +178,40 @@ describe("config", () => {
175178
});
176179
});
177180

181+
describe("#getCacheDataDir", () => {
182+
beforeEach(() => {
183+
config.load({});
184+
});
185+
186+
it("returns default path", () => {
187+
expect(config.getCacheDataDir()).toEqual(
188+
"/home/test-user/.cache/qiita-cli"
189+
);
190+
});
191+
192+
describe("with XDG_CACHE_HOME environment", () => {
193+
const xdgCacheHome = "/tmp/.cache";
194+
195+
const mockProcess = process as jest.Mocked<typeof process>;
196+
const env = mockProcess.env;
197+
beforeEach(() => {
198+
mockProcess.env = {
199+
...env,
200+
XDG_CACHE_HOME: xdgCacheHome,
201+
};
202+
203+
config.load({});
204+
});
205+
afterEach(() => {
206+
mockProcess.env = env;
207+
});
208+
209+
it("returns customized path", () => {
210+
expect(config.getCacheDataDir()).toEqual(`${xdgCacheHome}/qiita-cli`);
211+
});
212+
});
213+
});
214+
178215
describe("#getUserConfig", () => {
179216
const userConfigFilePath =
180217
"/home/test-user/qiita-articles/qiita.config.json";

src/lib/config.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ class Config {
2222
private userConfigFilePath?: string;
2323
private userConfigDir?: string;
2424
private credential?: Credential;
25+
private cacheDataDir?: string;
26+
private readonly packageName: string;
2527

26-
constructor() {}
28+
constructor() {
29+
this.packageName = "qiita-cli";
30+
}
2731

2832
load(options: Options) {
2933
this.credentialDir = this.resolveConfigDir(options.credentialDir);
@@ -32,6 +36,7 @@ class Config {
3236
this.userConfigFilePath = this.resolveUserConfigFilePath(
3337
options.userConfigDir
3438
);
39+
this.cacheDataDir = this.resolveCacheDataDir();
3540
this.credential = new Credential({
3641
credentialDir: this.credentialDir,
3742
profile: options.profile,
@@ -43,6 +48,7 @@ class Config {
4348
credentialDir: this.credentialDir,
4449
itemsRootDir: this.itemsRootDir,
4550
userConfigFilePath: this.userConfigFilePath,
51+
cacheDataDir: this.cacheDataDir,
4652
})
4753
);
4854
}
@@ -76,6 +82,13 @@ class Config {
7682
return this.userConfigFilePath;
7783
}
7884

85+
getCacheDataDir() {
86+
if (!this.cacheDataDir) {
87+
throw new Error("cacheDataDir is undefined");
88+
}
89+
return this.cacheDataDir;
90+
}
91+
7992
getCredential() {
8093
if (!this.credential) {
8194
throw new Error("credential is undefined");
@@ -109,15 +122,13 @@ class Config {
109122
}
110123

111124
private resolveConfigDir(credentialDirPath?: string) {
112-
const packageName = "qiita-cli";
113-
114125
if (process.env.XDG_CONFIG_HOME) {
115126
const credentialDir = process.env.XDG_CONFIG_HOME;
116-
return path.join(credentialDir, packageName);
127+
return path.join(credentialDir, this.packageName);
117128
}
118129
if (!credentialDirPath) {
119130
const homeDir = os.homedir();
120-
return path.join(homeDir, ".config", packageName);
131+
return path.join(homeDir, ".config", this.packageName);
121132
}
122133

123134
return this.resolveFullPath(credentialDirPath);
@@ -150,6 +161,12 @@ class Config {
150161
return path.join(this.resolveUserConfigDirPath(dirPath), filename);
151162
}
152163

164+
private resolveCacheDataDir() {
165+
const cacheHome =
166+
process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
167+
return path.join(cacheHome, this.packageName);
168+
}
169+
153170
private resolveFullPath(filePath: string) {
154171
if (path.isAbsolute(filePath)) {
155172
return filePath;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { PackageSettings } from "../package-settings";
2+
3+
export const fetchLatestPackageVersion = async () => {
4+
try {
5+
const response = await fetch(
6+
`https://registry.npmjs.org/${PackageSettings.name}/latest`
7+
);
8+
const json = await response.json();
9+
const latestVersion = json.version as string;
10+
11+
return latestVersion;
12+
} catch {
13+
return null;
14+
}
15+
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as fetchPackageVersion from "./fetch-package-version";
2+
import { getLatestPackageVersion } from "./get-latest-package-version";
3+
import type { CacheData } from "./package-version-cache";
4+
import * as packageVersionCache from "./package-version-cache";
5+
6+
describe("getLatestPackageVersion", () => {
7+
const mockFetchLatestPackageVersion = jest.spyOn(
8+
fetchPackageVersion,
9+
"fetchLatestPackageVersion"
10+
);
11+
const mockGetCacheData = jest.spyOn(packageVersionCache, "getCacheData");
12+
const mockSetCacheData = jest.spyOn(packageVersionCache, "setCacheData");
13+
const mockDateNow = jest.spyOn(Date, "now");
14+
15+
beforeEach(() => {
16+
mockFetchLatestPackageVersion.mockReset();
17+
mockGetCacheData.mockReset();
18+
mockSetCacheData.mockReset();
19+
});
20+
21+
describe("when cache exists and not expired", () => {
22+
const cacheData: CacheData = {
23+
lastCheckedAt: new Date("2023-07-13T00:00:00.000Z").getTime(),
24+
latestVersion: "0.0.0",
25+
};
26+
27+
beforeEach(() => {
28+
mockGetCacheData.mockReturnValue(cacheData);
29+
mockDateNow.mockReturnValue(
30+
new Date("2023-07-13T11:00:00.000Z").getTime()
31+
);
32+
});
33+
34+
it("returns cached version", async () => {
35+
expect(await getLatestPackageVersion()).toEqual("0.0.0");
36+
expect(mockGetCacheData).toHaveBeenCalled();
37+
expect(mockFetchLatestPackageVersion).not.toHaveBeenCalled();
38+
expect(mockDateNow).toHaveBeenCalled();
39+
expect(mockSetCacheData).not.toHaveBeenCalled();
40+
});
41+
});
42+
43+
describe("when cache exists but expired", () => {
44+
const cacheData: CacheData = {
45+
lastCheckedAt: new Date("2023-07-13T00:00:00.000Z").getTime(),
46+
latestVersion: "0.0.0",
47+
};
48+
const currentTime = new Date("2023-07-13T12:00:00.000Z").getTime();
49+
50+
beforeEach(() => {
51+
mockGetCacheData.mockReturnValue(cacheData);
52+
mockDateNow.mockReturnValue(currentTime);
53+
mockFetchLatestPackageVersion.mockResolvedValue("0.0.1");
54+
mockSetCacheData.mockReturnValue();
55+
});
56+
57+
it("returns latest version and updates cache", async () => {
58+
expect(await getLatestPackageVersion()).toEqual("0.0.1");
59+
expect(mockGetCacheData).toBeCalled();
60+
expect(mockDateNow).toHaveBeenCalled();
61+
expect(mockFetchLatestPackageVersion).toHaveBeenCalled();
62+
expect(mockSetCacheData).toHaveBeenCalledWith({
63+
lastCheckedAt: currentTime,
64+
latestVersion: "0.0.1",
65+
});
66+
});
67+
});
68+
69+
describe("when cache does not exist", () => {
70+
const currentTime = new Date("2023-07-13T12:00:00.000Z").getTime();
71+
72+
beforeEach(() => {
73+
mockGetCacheData.mockReturnValue(null);
74+
mockDateNow.mockReturnValue(currentTime);
75+
mockFetchLatestPackageVersion.mockResolvedValue("0.0.1");
76+
mockSetCacheData.mockReturnValue();
77+
});
78+
79+
it("returns latest version and updates cache", async () => {
80+
expect(await getLatestPackageVersion()).toEqual("0.0.1");
81+
expect(mockGetCacheData).toBeCalled();
82+
expect(mockDateNow).toHaveBeenCalled();
83+
expect(mockFetchLatestPackageVersion).toHaveBeenCalled();
84+
expect(mockSetCacheData).toHaveBeenCalledWith({
85+
lastCheckedAt: currentTime,
86+
latestVersion: "0.0.1",
87+
});
88+
});
89+
});
90+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { fetchLatestPackageVersion } from "./fetch-package-version";
2+
import { getCacheData, setCacheData } from "./package-version-cache";
3+
4+
const CACHE_EXPIRE_TIME = 1000 * 60 * 60 * 12; // 12 hours
5+
6+
export const getLatestPackageVersion = async () => {
7+
const cacheData = getCacheData();
8+
const now = Date.now();
9+
10+
if (cacheData) {
11+
const { lastCheckedAt, latestVersion } = cacheData;
12+
13+
if (now - lastCheckedAt < CACHE_EXPIRE_TIME) {
14+
return latestVersion;
15+
}
16+
}
17+
18+
const latestVersion = await fetchLatestPackageVersion();
19+
if (latestVersion) {
20+
setCacheData({
21+
lastCheckedAt: now,
22+
latestVersion,
23+
});
24+
}
25+
26+
return latestVersion;
27+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { getLatestPackageVersion } from "./get-latest-package-version";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { config } from "../config";
4+
5+
export interface CacheData {
6+
lastCheckedAt: number;
7+
latestVersion: string;
8+
}
9+
10+
const CACHE_FILE_NAME = "latest-package-version";
11+
12+
const cacheFilePath = () => {
13+
const cacheDir = config.getCacheDataDir();
14+
return path.join(cacheDir, CACHE_FILE_NAME);
15+
};
16+
17+
export const getCacheData = () => {
18+
const filePath = cacheFilePath();
19+
if (!fs.existsSync(filePath)) {
20+
return null;
21+
}
22+
23+
const data = fs.readFileSync(filePath, { encoding: "utf-8" });
24+
return JSON.parse(data) as CacheData;
25+
};
26+
27+
export const setCacheData = (data: CacheData) => {
28+
const cacheDir = config.getCacheDataDir();
29+
const filePath = cacheFilePath();
30+
31+
fs.mkdirSync(cacheDir, { recursive: true });
32+
fs.writeFileSync(filePath, JSON.stringify(data), {
33+
encoding: "utf-8",
34+
mode: 0o600,
35+
});
36+
};

src/lib/package-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const packageJsonData = require("../../package.json");
22

33
export const PackageSettings = {
4+
name: packageJsonData.name,
45
userAgentName: "QiitaCLI",
56
version: packageJsonData.version,
67
};

src/lib/package-update-notice.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getLatestPackageVersion } from "./get-latest-package-version";
2+
import { PackageSettings } from "./package-settings";
3+
4+
export const packageUpdateNotice = async () => {
5+
const currentVersion = PackageSettings.version;
6+
const latestVersion = await getLatestPackageVersion();
7+
8+
if (!latestVersion) {
9+
return null;
10+
}
11+
if (currentVersion === latestVersion) {
12+
return null;
13+
}
14+
15+
const chalk = (await import("chalk")).default; // `chalk` supports only ESM.
16+
const boxen = (await import("boxen")).default; // `boxen` supports only ESM.
17+
18+
let message = "新しいバージョンがあります! ";
19+
message += ` ${chalk.red(currentVersion)} -> ${chalk.green(latestVersion)}`;
20+
message += "\n";
21+
message += `${chalk.green(`npm install ${PackageSettings.name}@latest`)}`;
22+
message += " でアップデートできます!";
23+
24+
message = boxen(message, { padding: 1, margin: 1, borderStyle: "round" });
25+
26+
return message;
27+
};

src/server/api/items.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ const itemsShow = async (req: Express.Request, res: Express.Response) => {
7676
return;
7777
}
7878

79-
// const { data, itemPath, modified, published } = ;
8079
const { itemPath, modified, published } = item;
8180

8281
const qiitaApi = await getQiitaApiInstance();

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"esModuleInterop": true,
44
"forceConsistentCasingInFileNames": true,
55
"lib": ["es2020", "DOM"],
6-
"module": "commonjs",
6+
"module": "Node16",
77
"outDir": "./dist",
88
"rootDir": "./src",
99
"skipLibCheck": false,

0 commit comments

Comments
 (0)