Skip to content

Commit e096219

Browse files
committed
feat: show cached account status and Pushy renewal reminders
1 parent 12aba20 commit e096219

8 files changed

Lines changed: 349 additions & 7 deletions

File tree

‎src/account-worker.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { get, replaceSession, setApiToken } from './api';
4+
5+
// Isolated from the command: even DNS, endpoint probing and slow sockets cannot
6+
// keep the CLI alive. Bound the detached worker's lifetime as well.
7+
if (require.main === module) {
8+
const deadline = setTimeout(() => process.exit(0), 15000);
9+
process.once(
10+
'message',
11+
async (input: { token: string; apiToken?: string; cacheFile: string }) => {
12+
try {
13+
replaceSession({ token: input.token });
14+
if (input.apiToken) setApiToken(input.apiToken);
15+
const result = await get('/user/me');
16+
const account = {
17+
name: result.name,
18+
email: result.email,
19+
tier: result.tier,
20+
tierExpiresAt: result.tierExpiresAt,
21+
serverTime: result.serverTime,
22+
};
23+
const entry = { account, fetchedAt: Date.now() };
24+
fs.mkdirSync(path.dirname(input.cacheFile), {
25+
recursive: true,
26+
mode: 0o700,
27+
});
28+
const temporary = `${input.cacheFile}.${process.pid}.tmp`;
29+
fs.writeFileSync(temporary, JSON.stringify(entry), { mode: 0o600 });
30+
fs.renameSync(temporary, input.cacheFile);
31+
if (process.connected) {
32+
process.send?.(entry, () => process.exit(0));
33+
return;
34+
}
35+
} catch {
36+
// Background refresh is best effort; retain the last successful cache.
37+
}
38+
clearTimeout(deadline);
39+
process.exit(0);
40+
},
41+
);
42+
}

‎src/account.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import chalk from 'chalk';
2+
import { fork } from 'child_process';
3+
import { createHash } from 'crypto';
4+
import fs from 'fs';
5+
import os from 'os';
6+
import path from 'path';
7+
import { getApiToken, getSession } from './api';
8+
import { IS_CRESC, pricingPageUrl } from './utils/constants';
9+
import { t } from './utils/i18n';
10+
11+
export interface AccountInfo {
12+
name?: string;
13+
email?: string;
14+
tier?: string;
15+
tierExpiresAt?: string | null;
16+
serverTime?: string;
17+
}
18+
19+
const DAY_MS = 24 * 60 * 60 * 1000;
20+
21+
export function printAccountInfo(
22+
account: AccountInfo,
23+
isCresc = IS_CRESC,
24+
now = Date.now(),
25+
) {
26+
const expiresAt = account.tierExpiresAt
27+
? Date.parse(account.tierExpiresAt)
28+
: NaN;
29+
const serverTime = account.serverTime ? Date.parse(account.serverTime) : NaN;
30+
const remaining =
31+
expiresAt - (Number.isFinite(serverTime) ? serverTime : now);
32+
console.log(
33+
t('accountSummary', {
34+
account: account.email || account.name || t('accountUnknown'),
35+
tier: account.tier || t('accountUnknown'),
36+
expiry: Number.isFinite(expiresAt)
37+
? new Date(expiresAt).toISOString()
38+
: t('accountNoExpiry'),
39+
}),
40+
);
41+
if (
42+
!isCresc &&
43+
account.tier &&
44+
account.tier !== 'free' &&
45+
remaining >= 0 &&
46+
remaining < 30 * DAY_MS
47+
) {
48+
console.warn(
49+
chalk.yellow.bold(
50+
t('accountRenewalWarning', {
51+
days: Math.ceil(remaining / DAY_MS),
52+
url: pricingPageUrl,
53+
}),
54+
),
55+
);
56+
}
57+
}
58+
59+
interface AccountCache {
60+
account: AccountInfo;
61+
fetchedAt: number;
62+
}
63+
64+
export function accountCacheFile(token: string, apiToken = '') {
65+
const key = createHash('sha256')
66+
.update(JSON.stringify([IS_CRESC, process.cwd(), token, apiToken]))
67+
.digest('hex');
68+
return path.join(
69+
os.homedir(),
70+
'.cache',
71+
'react-native-update-cli',
72+
'accounts',
73+
`${key}.json`,
74+
);
75+
}
76+
77+
function printCachedAccount(entry: AccountCache) {
78+
const serverTime = Date.parse(entry.account.serverTime || '');
79+
// Advance cached server time by elapsed local time, instead of freezing the
80+
// remaining subscription duration at the moment the cache was written.
81+
printAccountInfo({
82+
...entry.account,
83+
serverTime: Number.isFinite(serverTime)
84+
? new Date(
85+
serverTime + Math.max(0, Date.now() - entry.fetchedAt),
86+
).toISOString()
87+
: undefined,
88+
});
89+
}
90+
91+
/** Cache first; the detached refresh never keeps the command process alive. */
92+
export function showCurrentAccount() {
93+
const token = getSession()?.token;
94+
if (!token) return;
95+
const apiToken = getApiToken();
96+
const cacheFile = accountCacheFile(token, apiToken);
97+
let cached = false;
98+
try {
99+
const entry: AccountCache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
100+
if (
101+
entry.account &&
102+
typeof entry.account === 'object' &&
103+
Number.isFinite(entry.fetchedAt)
104+
) {
105+
printCachedAccount(entry);
106+
cached = true;
107+
}
108+
} catch {
109+
// Missing or corrupt cache is a cache miss.
110+
}
111+
try {
112+
const worker = fork(
113+
path.join(__dirname, `account-worker${path.extname(__filename)}`),
114+
[],
115+
{
116+
detached: true,
117+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
118+
execArgv: [],
119+
},
120+
);
121+
worker.on('error', () => {});
122+
worker.on('message', (entry: AccountCache) => {
123+
if (!cached) {
124+
printCachedAccount(entry);
125+
cached = true;
126+
}
127+
});
128+
worker.send({ token, apiToken, cacheFile }, () => {});
129+
worker.unref();
130+
worker.channel?.unref();
131+
} catch {
132+
// Starting a background worker is also best effort.
133+
}
134+
}

‎src/bin.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
22

3+
import { showCurrentAccount } from './account';
34
import { loadSession } from './api';
45
import { commandNames, loadCommandHandler } from './commands';
56
import { printVersionCommand } from './utils';
@@ -98,6 +99,9 @@ async function run() {
9899
if (!handler) {
99100
throw new Error(t('unknownCommand', { command: argv.command }));
100101
}
102+
if (argv.command !== 'me') {
103+
showCurrentAccount();
104+
}
101105
await handler(argv);
102106
// a check still in flight (cold cache) gets a short grace period; the
103107
// registry request itself is unref'd, so exiting never waits on it

‎src/locales/en.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export default {
2+
accountSummary:
3+
'Account: {{account}} | Plan: {{tier}} | Expires at: {{expiry}}',
4+
accountUnknown: 'Unknown',
5+
accountNoExpiry: 'No expiry specified',
6+
accountRenewalWarning:
7+
'[Warning] Your Pushy paid plan expires in {{days}} days. Renew soon to avoid service disruption: {{url}}',
8+
accountLookupFailed:
9+
'[Warning] Unable to retrieve account information. Continuing with the command.',
210
addedToGitignore: 'Added {{line}} to .gitignore',
311
androidCrunchPngsWarning:
412
'The crunchPngs option of android seems not disabled (Please ignore this warning if already disabled), which may cause abnormal consumption of mobile network traffic. Please refer to https://cresc.dev/docs/getting-started#disable-crunchpngs-on-android \n',

‎src/locales/zh.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
export default {
2+
accountSummary:
3+
'当前账号:{{account}} | 等级:{{tier}} | 有效期至:{{expiry}}',
4+
accountUnknown: '未知',
5+
accountNoExpiry: '未设置有效期',
6+
accountRenewalWarning:
7+
'[警告] 您的 Pushy 付费套餐将在 {{days}} 天内到期,请及时续费,以免影响服务:{{url}}',
8+
accountLookupFailed: '[警告] 暂时无法获取账号信息,将继续执行命令。',
29
addedToGitignore: '已将 {{line}} 添加到 .gitignore',
310
androidCrunchPngsWarning:
411
'android 的 crunchPngs 选项似乎尚未禁用(如已禁用则请忽略此提示),这可能导致热更包体积异常增大,具体请参考 https://pushy.reactnative.cn/docs/getting-started.html#%E7%A6%81%E7%94%A8-android-%E7%9A%84-crunch-%E4%BC%98%E5%8C%96 \n',

‎src/user.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from 'crypto';
2+
import { printAccountInfo } from './account';
23
import { closeSession, get, post, replaceSession, saveSession } from './api';
34
import type { CommandContext } from './types';
45
import { question } from './utils';
@@ -36,6 +37,7 @@ export const userCommands = {
3637
},
3738
me: async () => {
3839
const me = await get('/user/me');
40+
printAccountInfo(me);
3941
for (const k in me) {
4042
if (k !== 'ok') {
4143
console.log(`${k}: ${me[k]}`);

‎tests/account.test.ts‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test';
2+
import * as childProcess from 'child_process';
3+
import { EventEmitter } from 'events';
4+
import fs from 'fs';
5+
import {
6+
accountCacheFile,
7+
printAccountInfo,
8+
showCurrentAccount,
9+
} from '../src/account';
10+
import * as api from '../src/api';
11+
12+
const now = Date.parse('2026-09-17T00:00:00Z');
13+
const day = 86400000;
14+
let log: ReturnType<typeof spyOn>;
15+
let warn: ReturnType<typeof spyOn>;
16+
let get: ReturnType<typeof spyOn>;
17+
let session: ReturnType<typeof spyOn>;
18+
let fork: ReturnType<typeof spyOn>;
19+
let read: ReturnType<typeof spyOn>;
20+
let worker: any;
21+
22+
beforeEach(() => {
23+
worker = Object.assign(new EventEmitter(), {
24+
send() {},
25+
unref() {},
26+
channel: { unref() {} },
27+
});
28+
fork = spyOn(childProcess, 'fork').mockReturnValue(worker);
29+
read = spyOn(fs, 'readFileSync').mockImplementation(() => {
30+
throw new Error('no cache');
31+
});
32+
log = spyOn(console, 'log').mockImplementation(() => {});
33+
warn = spyOn(console, 'warn').mockImplementation(() => {});
34+
get = spyOn(api, 'get').mockResolvedValue({
35+
email: 'test@example.com',
36+
tier: 'pro',
37+
});
38+
session = spyOn(api, 'getSession').mockReturnValue({ token: 'test-token' });
39+
});
40+
afterEach(() => {
41+
fork.mockRestore();
42+
read.mockRestore();
43+
log.mockRestore();
44+
warn.mockRestore();
45+
get.mockRestore();
46+
session.mockRestore();
47+
});
48+
49+
for (const days of [0, 1, 29, 29.99, 30, 31, -1]) {
50+
test(`Pushy renewal boundary: ${days} days`, () => {
51+
printAccountInfo(
52+
{
53+
email: 'test@example.com',
54+
tier: 'pro',
55+
tierExpiresAt: new Date(now + days * day).toISOString(),
56+
serverTime: new Date(now).toISOString(),
57+
},
58+
false,
59+
now + 100 * day,
60+
);
61+
expect(warn).toHaveBeenCalledTimes(days >= 0 && days < 30 ? 1 : 0);
62+
expect(log.mock.calls[0][0]).toContain('test@example.com');
63+
expect(log.mock.calls[0][0]).toContain('pro');
64+
expect(log.mock.calls[0][0]).toContain(
65+
new Date(now + days * day).toISOString(),
66+
);
67+
});
68+
}
69+
70+
test('Cresc and free accounts never receive renewal reminders', () => {
71+
const tierExpiresAt = new Date(now + day).toISOString();
72+
printAccountInfo({ tier: 'pro', tierExpiresAt }, true, now);
73+
printAccountInfo({ tier: 'free', tierExpiresAt }, false, now);
74+
expect(warn).not.toHaveBeenCalled();
75+
});
76+
77+
test('missing and invalid expiry dates never produce renewal reminders', () => {
78+
for (const tierExpiresAt of [undefined, null, '', 'invalid']) {
79+
printAccountInfo({ tier: 'pro', tierExpiresAt }, false, now);
80+
}
81+
expect(warn).not.toHaveBeenCalled();
82+
});
83+
84+
test('uses local time when server time is unavailable', () => {
85+
printAccountInfo(
86+
{ tier: 'custom', tierExpiresAt: new Date(now + day).toISOString() },
87+
false,
88+
now,
89+
);
90+
expect(warn).toHaveBeenCalledTimes(1);
91+
});
92+
93+
test('without a local session no account request is made', async () => {
94+
session.mockReturnValue(undefined);
95+
await showCurrentAccount();
96+
expect(get).not.toHaveBeenCalled();
97+
});
98+
99+
test('refresh starts without awaiting the network', () => {
100+
expect(showCurrentAccount()).toBeUndefined();
101+
expect(fork).toHaveBeenCalledTimes(1);
102+
expect(get).not.toHaveBeenCalled();
103+
worker.emit('message', {
104+
account: { email: 'fresh@example.com' },
105+
fetchedAt: Date.now(),
106+
});
107+
expect(log.mock.calls[0][0]).toContain('fresh@example.com');
108+
});
109+
110+
test('cached result displays immediately and is not printed twice', () => {
111+
read.mockReturnValue(
112+
JSON.stringify({
113+
account: { email: 'cached@example.com' },
114+
fetchedAt: Date.now(),
115+
}),
116+
);
117+
showCurrentAccount();
118+
expect(log.mock.calls[0][0]).toContain('cached@example.com');
119+
worker.emit('message', {
120+
account: { email: 'fresh@example.com' },
121+
fetchedAt: Date.now(),
122+
});
123+
expect(log).toHaveBeenCalledTimes(1);
124+
});
125+
126+
test('cached server clock advances so renewal reminders remain timely', () => {
127+
read.mockReturnValue(
128+
JSON.stringify({
129+
account: {
130+
tier: 'pro',
131+
serverTime: new Date(now).toISOString(),
132+
tierExpiresAt: new Date(now + 35 * day).toISOString(),
133+
},
134+
fetchedAt: Date.now() - 10 * day,
135+
}),
136+
);
137+
showCurrentAccount();
138+
expect(warn).toHaveBeenCalledTimes(1);
139+
});
140+
141+
test('worker failures do not block the command', () => {
142+
fork.mockImplementation(() => {
143+
throw new Error('spawn failed');
144+
});
145+
expect(showCurrentAccount()).toBeUndefined();
146+
});
147+
148+
test('cache keys isolate credentials and never contain tokens', () => {
149+
expect(accountCacheFile('one')).not.toBe(accountCacheFile('two'));
150+
expect(accountCacheFile('one', 'api')).not.toBe(accountCacheFile('one'));
151+
expect(accountCacheFile('secret-token')).not.toContain('secret-token');
152+
});

0 commit comments

Comments
 (0)