@postplus/cli 0.1.13 → 0.1.15

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/README.md CHANGED
@@ -11,7 +11,7 @@ Instead of asking you to choose tools first, PostPlus starts from the job you wa
11
11
  ```text
12
12
  "Find creators for this product."
13
13
  "Analyze why this competitor video works."
14
- "Tell me whether this product has TikTok Shop potential."
14
+ "Tell me whether this product has marketplace potential."
15
15
  "Turn these references into a short-form video brief."
16
16
  "Package the research into a client-ready Feishu report."
17
17
  ```
@@ -33,7 +33,7 @@ Requires Node.js and npm.
33
33
  ```bash
34
34
  npm install -g @postplus/cli
35
35
  postplus auth login
36
- npx -y skills add PostPlusAI/postplus-skills --full-depth --skill '*' --agent claude-code codex cursor --yes
36
+ npx -y skills add PostPlusAI/postplus-skills --full-depth --skill '*' --agent claude-code codex cursor github-copilot windsurf trae trae-cn --yes
37
37
  ```
38
38
 
39
39
  Useful checks:
@@ -162,7 +162,7 @@ Use PostPlus when you need to decide whether a product, category, or channel is
162
162
  Example requests:
163
163
 
164
164
  ```text
165
- "Does this product fit TikTok Shop or Amazon better?"
165
+ "Does this product fit Amazon or a content-led launch better?"
166
166
  "Find 1688 suppliers and compare them against demand signals."
167
167
  "Analyze whether this category has enough content proof to test."
168
168
  ```
@@ -244,7 +244,7 @@ Typical outputs:
244
244
 
245
245
  ```text
246
246
  Product idea
247
- -> collect TikTok, Amazon, TikTok Shop, Xiaohongshu, Google Trends, or 1688 evidence
247
+ -> collect TikTok, Amazon, Xiaohongshu, Google Trends, or 1688 evidence
248
248
  -> compare demand, content fit, supply, price, and risk
249
249
  -> produce a go / no-go / test-first recommendation
250
250
  ```
@@ -305,7 +305,7 @@ Examples: social media routing, creator discovery routing, media routing, patter
305
305
 
306
306
  For collecting and analyzing public signals from platforms, marketplaces, search behavior, and social content.
307
307
 
308
- Examples: TikTok, TikTok ads, Instagram, X, YouTube, LinkedIn, Facebook, Xiaohongshu, 1688, Amazon, TikTok Shop, Google Trends.
308
+ Examples: TikTok, TikTok ads, Instagram, X, YouTube, LinkedIn, Facebook, Xiaohongshu, 1688, Amazon, Google Trends.
309
309
 
310
310
  ### 3. Decide and Shortlist
311
311
 
@@ -345,7 +345,7 @@ This is not a full catalog. It is a practical map of the problems PostPlus is me
345
345
  |---|---|---|
346
346
  | Understand a market or audience | Topic listening, trend discovery, competitor snapshots, comment mining, audience language, demand signals | TikTok, Instagram, X, YouTube, LinkedIn, Facebook, Xiaohongshu, Google Trends, Amazon reviews |
347
347
  | Find creators or KOL/KOC partners | Creator discovery, profile enrichment, content-fit scoring, shortlist building, contact signal extraction, outreach prep | TikTok creators, Instagram creators, Xiaohongshu accounts, X accounts, creator graph, follower bands, engagement proxy |
348
- | Decide whether a product is worth testing | Product selection, marketplace comparison, channel fit, price bands, review analysis, supply-side checks, sourcing judgment | Amazon, TikTok Shop, 1688, Google Trends, Xiaohongshu commerce, supplier ranking, SKU, MOQ, margin risk |
348
+ | Decide whether a product is worth testing | Product selection, marketplace comparison, channel fit, price bands, review analysis, supply-side checks, sourcing judgment | Amazon, 1688, Google Trends, Xiaohongshu commerce, supplier ranking, SKU, MOQ, margin risk |
349
349
  | Turn references into creative direction | Reference decoding, hook analysis, visual grammar, benchmark-to-brief, persona packs, storyboard planning, prompt QA | TikTok videos, Reels, Xiaohongshu notes, short-form hooks, UGC, product demo, lifestyle, testimonial |
350
350
  | Produce media assets | Transcription, subtitles, frame extraction, B-roll planning, image generation, video generation, voice generation, edit packaging | Whisper, SRT/VTT/ASS, B-roll, storyboard grid, hosted media generation, image prompts, video requests |
351
351
  | Plan content and messaging | Positioning, content strategy, copywriting, social content, email sequences, SEO, AI search, launch planning | Blog, landing page, LinkedIn, X, Xiaohongshu, cold email, content pillars, hooks, objections, offers |
@@ -1,53 +1,16 @@
1
+ import { refreshRemoteAuthSession } from './auth-session.js';
1
2
  import { clearAuthState, generateAuthStatusReport } from './auth.js';
2
3
  import { requireHostedBaseUrl } from './hosted-release.js';
3
- import { resolveAccessTokenState, resolveRefreshTokenState, setLocalSession, } from './local-state.js';
4
+ import { resolveAccessTokenState, resolveRefreshTokenState, } from './local-state.js';
4
5
  export async function refreshRemoteAuth() {
5
- const [apiBaseUrl, accessTokenState, refreshTokenState] = await Promise.all([
6
- requireHostedBaseUrl(),
7
- resolveAccessTokenState(),
8
- resolveRefreshTokenState(),
9
- ]);
10
- if (!refreshTokenState.present || !refreshTokenState.value) {
11
- throw new Error('Run `postplus auth login` before refreshing PostPlus auth.');
12
- }
13
- const response = await fetch(`${apiBaseUrl}/api/postplus-cli/auth/refresh`, {
14
- method: 'POST',
15
- headers: {
16
- accept: 'application/json',
17
- ...(accessTokenState.value
18
- ? { authorization: `Bearer ${accessTokenState.value}` }
19
- : {}),
20
- 'content-type': 'application/json',
21
- },
22
- body: JSON.stringify({
23
- refreshToken: refreshTokenState.value,
24
- }),
25
- signal: AbortSignal.timeout(15000),
26
- });
27
- const payload = (await response.json());
28
- if (!response.ok) {
29
- throw new Error('error' in payload && typeof payload.error === 'string'
30
- ? payload.error
31
- : 'Failed to refresh remote PostPlus auth.');
32
- }
33
- const successPayload = payload;
34
- await setLocalSession({
35
- accessToken: successPayload.accessToken,
36
- accountId: successPayload.accountId,
37
- apiBaseUrl,
38
- refreshToken: successPayload.refreshToken,
39
- sessionExpiresAt: successPayload.sessionExpiresAt,
40
- userEmail: successPayload.userEmail,
41
- userId: successPayload.userId,
42
- });
6
+ const refreshed = await refreshRemoteAuthSession();
43
7
  return {
44
- accessTokenExpiresAt: successPayload.sessionExpiresAt,
45
- accountId: successPayload.accountId,
46
- apiBaseUrl,
8
+ accountId: refreshed.accountId,
9
+ apiBaseUrl: refreshed.apiBaseUrl,
47
10
  ok: true,
48
- subscriptionStatus: successPayload.subscriptionStatus,
49
- userEmail: successPayload.userEmail,
50
- userId: successPayload.userId,
11
+ subscriptionStatus: refreshed.subscriptionStatus,
12
+ userEmail: refreshed.userEmail,
13
+ userId: refreshed.userId,
51
14
  };
52
15
  }
53
16
  export function formatAuthRefreshReport(report) {
@@ -59,9 +22,6 @@ export function formatAuthRefreshReport(report) {
59
22
  `Account: ${report.accountId}`,
60
23
  `User: ${report.userEmail ?? report.userId}`,
61
24
  `Subscription: ${report.subscriptionStatus ?? 'unknown'}`,
62
- `Session expires at: ${typeof report.accessTokenExpiresAt === 'number'
63
- ? new Date(report.accessTokenExpiresAt * 1000).toISOString()
64
- : 'unknown'}`,
65
25
  ].join('\n');
66
26
  }
67
27
  export async function revokeRemoteAuth() {
@@ -1,60 +1,104 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { createServer } from 'node:http';
3
1
  import { requireHostedBaseUrl } from './hosted-release.js';
4
2
  import { setLocalSession } from './local-state.js';
5
- export async function loginWithBrowserHandoff() {
3
+ export const CLI_AUTH_LOGIN_TIMEOUT_MS = 30 * 60 * 1000;
4
+ export async function loginWithCloudHandoff() {
6
5
  const baseUrl = await requireHostedBaseUrl();
7
- const handoff = await createCliAuthHandoffServer({
8
- allowedOrigin: new URL(baseUrl).origin,
9
- });
10
- const loginUrl = buildCliLoginUrl({
11
- baseUrl,
12
- bridgeUrl: handoff.bridgeUrl,
13
- requestId: handoff.requestId,
14
- });
6
+ const started = await startCloudAuthLogin(baseUrl);
15
7
  process.stdout.write([
16
8
  'PostPlus CLI login',
17
9
  '',
18
10
  'Open this URL in your browser to continue:',
19
- loginUrl,
11
+ started.verificationUrl,
12
+ '',
13
+ `Code: ${started.userCode}`,
20
14
  '',
21
15
  'Waiting for browser sign-in...',
22
16
  '',
23
17
  ].join('\n'));
24
- try {
25
- const handoffPayload = await handoff.waitForPayload();
26
- if ('error' in handoffPayload) {
27
- throw new Error(handoffPayload.error);
28
- }
29
- const validated = await validateCliSession({
30
- accessToken: handoffPayload.accessToken,
31
- apiBaseUrl: baseUrl,
32
- });
33
- await setLocalSession({
34
- accessToken: handoffPayload.accessToken,
35
- accountId: validated.accountId,
36
- apiBaseUrl: baseUrl,
37
- refreshToken: handoffPayload.refreshToken,
38
- sessionExpiresAt: validated.sessionExpiresAt ?? handoffPayload.expiresAt ?? null,
39
- userEmail: validated.userEmail,
40
- userId: validated.userId,
41
- });
42
- return {
43
- accountId: validated.accountId,
44
- apiBaseUrl: baseUrl,
45
- ok: true,
46
- sessionExpiresAt: validated.sessionExpiresAt ?? handoffPayload.expiresAt ?? null,
47
- userEmail: validated.userEmail,
48
- userId: validated.userId,
49
- };
18
+ const handoffPayload = await waitForCloudAuthLogin({
19
+ apiBaseUrl: baseUrl,
20
+ expiresAt: started.expiresAt,
21
+ pollIntervalSeconds: started.pollIntervalSeconds,
22
+ pollSecret: started.pollSecret,
23
+ requestId: started.requestId,
24
+ });
25
+ const validated = await validateCliSession({
26
+ accessToken: handoffPayload.accessToken,
27
+ apiBaseUrl: baseUrl,
28
+ });
29
+ await setLocalSession({
30
+ accessToken: handoffPayload.accessToken,
31
+ accountId: validated.accountId,
32
+ apiBaseUrl: baseUrl,
33
+ refreshToken: handoffPayload.refreshToken,
34
+ sessionExpiresAt: validated.sessionExpiresAt ?? handoffPayload.sessionExpiresAt ?? null,
35
+ userEmail: validated.userEmail,
36
+ userId: validated.userId,
37
+ });
38
+ return {
39
+ accountId: validated.accountId,
40
+ apiBaseUrl: baseUrl,
41
+ ok: true,
42
+ userEmail: validated.userEmail,
43
+ userId: validated.userId,
44
+ };
45
+ }
46
+ export async function startCloudAuthLogin(apiBaseUrl) {
47
+ const response = await fetch(`${apiBaseUrl}/api/postplus-cli/auth/login/start`, {
48
+ method: 'POST',
49
+ headers: {
50
+ accept: 'application/json',
51
+ },
52
+ signal: AbortSignal.timeout(15000),
53
+ });
54
+ const payload = (await response.json());
55
+ if (!response.ok) {
56
+ throw new Error(formatRemoteAuthLoginError(payload));
50
57
  }
51
- finally {
52
- await handoff.close();
58
+ if (!isCliAuthLoginStartSuccessPayload(payload)) {
59
+ throw new Error('PostPlus CLI sign-in start returned incomplete data.');
60
+ }
61
+ return payload;
62
+ }
63
+ async function waitForCloudAuthLogin(input) {
64
+ const expiresAtMs = Date.parse(input.expiresAt);
65
+ const deadlineMs = Number.isFinite(expiresAtMs)
66
+ ? expiresAtMs
67
+ : Date.now() + CLI_AUTH_LOGIN_TIMEOUT_MS;
68
+ const pollIntervalMs = Math.max(1000, input.pollIntervalSeconds * 1000);
69
+ while (Date.now() < deadlineMs) {
70
+ const payload = await pollCloudAuthLogin(input);
71
+ if (payload.status === 'completed') {
72
+ return payload;
73
+ }
74
+ await delay(pollIntervalMs);
53
75
  }
76
+ throw new Error('Timed out waiting for the cloud sign-in handoff.');
54
77
  }
55
- function buildCliLoginUrl(input) {
56
- const nextPath = `/auth/cli-callback?bridgeUrl=${encodeURIComponent(input.bridgeUrl)}&requestId=${encodeURIComponent(input.requestId)}`;
57
- return `${input.baseUrl}/auth/sign-in?next=${encodeURIComponent(nextPath)}`;
78
+ export async function pollCloudAuthLogin(input) {
79
+ const response = await fetch(`${input.apiBaseUrl}/api/postplus-cli/auth/login/poll`, {
80
+ method: 'POST',
81
+ headers: {
82
+ accept: 'application/json',
83
+ 'content-type': 'application/json',
84
+ },
85
+ body: JSON.stringify({
86
+ pollSecret: input.pollSecret,
87
+ requestId: input.requestId,
88
+ }),
89
+ signal: AbortSignal.timeout(15000),
90
+ });
91
+ const payload = (await response.json());
92
+ if (!response.ok) {
93
+ throw new Error(formatRemoteAuthLoginError(payload));
94
+ }
95
+ if (isCliAuthLoginCompletedPayload(payload)) {
96
+ return payload;
97
+ }
98
+ if (isCliAuthLoginPendingPayload(payload)) {
99
+ return payload;
100
+ }
101
+ throw new Error('PostPlus CLI sign-in poll returned incomplete data.');
58
102
  }
59
103
  export async function validateCliSession(input) {
60
104
  const response = await fetch(`${input.apiBaseUrl}/api/postplus-cli/auth/whoami`, {
@@ -84,124 +128,33 @@ export function formatCliSessionAuthError(payload) {
84
128
  }
85
129
  return 'Failed to validate the browser session for PostPlus CLI.';
86
130
  }
87
- async function createCliAuthHandoffServer(input) {
88
- const requestId = randomUUID();
89
- return new Promise((resolve, reject) => {
90
- let settled = false;
91
- let cleanupTimer = null;
92
- let resolvePayload = null;
93
- let rejectPayload = null;
94
- const payloadPromise = new Promise((innerResolve, innerReject) => {
95
- resolvePayload = innerResolve;
96
- rejectPayload = innerReject;
97
- });
98
- const server = createServer((request, response) => {
99
- const origin = request.headers.origin ?? null;
100
- const allowOrigin = origin === input.allowedOrigin ? input.allowedOrigin : null;
101
- if (request.method === 'OPTIONS') {
102
- if (!allowOrigin) {
103
- response.writeHead(403);
104
- response.end();
105
- return;
106
- }
107
- response.writeHead(204, {
108
- 'Access-Control-Allow-Headers': 'Content-Type',
109
- 'Access-Control-Allow-Methods': 'POST, OPTIONS',
110
- 'Access-Control-Allow-Origin': allowOrigin,
111
- 'Access-Control-Max-Age': '600',
112
- Vary: 'Origin',
113
- });
114
- response.end();
115
- return;
116
- }
117
- if (request.method !== 'POST' || request.url !== '/handoff') {
118
- response.writeHead(404);
119
- response.end();
120
- return;
121
- }
122
- if (!allowOrigin) {
123
- response.writeHead(403);
124
- response.end();
125
- return;
126
- }
127
- const chunks = [];
128
- request.on('data', (chunk) => {
129
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
130
- });
131
- request.on('end', () => {
132
- try {
133
- const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
134
- if (payload.requestId !== requestId) {
135
- throw new Error('Mismatched CLI auth handoff request id.');
136
- }
137
- response.writeHead(200, {
138
- 'Access-Control-Allow-Origin': allowOrigin,
139
- 'Content-Type': 'application/json',
140
- Vary: 'Origin',
141
- });
142
- response.end(JSON.stringify({ ok: true }));
143
- if (!settled) {
144
- settled = true;
145
- resolvePayload?.(payload);
146
- }
147
- }
148
- catch (error) {
149
- response.writeHead(400, {
150
- 'Access-Control-Allow-Origin': allowOrigin,
151
- 'Content-Type': 'application/json',
152
- Vary: 'Origin',
153
- });
154
- response.end(JSON.stringify({
155
- error: error instanceof Error
156
- ? error.message
157
- : 'Invalid CLI auth handoff payload.',
158
- }));
159
- }
160
- });
161
- });
162
- server.on('error', (error) => {
163
- if (!settled) {
164
- settled = true;
165
- reject(error);
166
- }
167
- else {
168
- rejectPayload?.(error);
169
- }
170
- });
171
- server.listen(0, '127.0.0.1', () => {
172
- const address = server.address();
173
- if (!address || typeof address === 'string') {
174
- const error = new Error('Failed to bind the local CLI auth bridge.');
175
- if (!settled) {
176
- settled = true;
177
- reject(error);
178
- }
179
- return;
180
- }
181
- cleanupTimer = setTimeout(() => {
182
- if (!settled) {
183
- settled = true;
184
- rejectPayload?.(new Error('Timed out waiting for the browser sign-in handoff.'));
185
- }
186
- server.close();
187
- }, 5 * 60 * 1000);
188
- resolve({
189
- bridgeUrl: `http://127.0.0.1:${address.port}/handoff`,
190
- close: async () => new Promise((innerResolve, innerReject) => {
191
- if (cleanupTimer) {
192
- clearTimeout(cleanupTimer);
193
- }
194
- server.close((error) => {
195
- if (error) {
196
- innerReject(error);
197
- return;
198
- }
199
- innerResolve();
200
- });
201
- }),
202
- requestId,
203
- waitForPayload: () => payloadPromise,
204
- });
205
- });
206
- });
131
+ function isCliAuthLoginStartSuccessPayload(payload) {
132
+ return ('requestId' in payload &&
133
+ typeof payload.requestId === 'string' &&
134
+ typeof payload.pollSecret === 'string' &&
135
+ typeof payload.userCode === 'string' &&
136
+ typeof payload.verificationUrl === 'string' &&
137
+ typeof payload.expiresAt === 'string' &&
138
+ typeof payload.pollIntervalSeconds === 'number');
139
+ }
140
+ function isCliAuthLoginCompletedPayload(payload) {
141
+ return ('status' in payload &&
142
+ payload.status === 'completed' &&
143
+ typeof payload.accessToken === 'string' &&
144
+ typeof payload.refreshToken === 'string' &&
145
+ typeof payload.accountId === 'string' &&
146
+ typeof payload.userId === 'string');
147
+ }
148
+ function isCliAuthLoginPendingPayload(payload) {
149
+ return 'status' in payload && payload.status === 'pending';
150
+ }
151
+ function formatRemoteAuthLoginError(payload) {
152
+ return 'error' in payload &&
153
+ typeof payload.error === 'string' &&
154
+ payload.error.trim().length > 0
155
+ ? payload.error
156
+ : 'PostPlus CLI sign-in failed.';
157
+ }
158
+ function delay(ms) {
159
+ return new Promise((resolve) => setTimeout(resolve, ms));
207
160
  }
@@ -0,0 +1,137 @@
1
+ import { requireHostedBaseUrl } from './hosted-release.js';
2
+ import { readLocalConfig, resolveAccessTokenState, resolveRefreshTokenState, setLocalSession, } from './local-state.js';
3
+ export const AUTH_SESSION_REFRESH_LEEWAY_SECONDS = 60;
4
+ export async function resolveFreshRemoteAuth(options = {}) {
5
+ const [apiBaseUrl, accessTokenState, refreshTokenState, config] = await Promise.all([
6
+ requireHostedBaseUrl(),
7
+ resolveAccessTokenState(),
8
+ resolveRefreshTokenState(),
9
+ readLocalConfig(),
10
+ ]);
11
+ if (!refreshTokenState.present || !refreshTokenState.value) {
12
+ if (!accessTokenState.present || !accessTokenState.value) {
13
+ throw new Error('Run `postplus auth login` before validating PostPlus auth.');
14
+ }
15
+ return {
16
+ accessToken: accessTokenState.value,
17
+ apiBaseUrl,
18
+ refreshed: false,
19
+ source: 'config',
20
+ };
21
+ }
22
+ const existingAccessToken = accessTokenState.value;
23
+ const decodedTokenExpiresAt = existingAccessToken
24
+ ? decodeAccessTokenExpiration(existingAccessToken)
25
+ : null;
26
+ const tokenExpiresAt = typeof decodedTokenExpiresAt === 'number'
27
+ ? decodedTokenExpiresAt
28
+ : typeof config?.sessionExpiresAt === 'number'
29
+ ? config.sessionExpiresAt
30
+ : null;
31
+ const shouldRefresh = options.forceRefresh === true ||
32
+ !accessTokenState.present ||
33
+ !accessTokenState.value ||
34
+ isExpiringSoon(tokenExpiresAt);
35
+ if (!shouldRefresh) {
36
+ if (!existingAccessToken) {
37
+ throw new Error('Run `postplus auth login` before validating PostPlus auth.');
38
+ }
39
+ return {
40
+ accessToken: existingAccessToken,
41
+ apiBaseUrl,
42
+ refreshed: false,
43
+ source: 'config',
44
+ };
45
+ }
46
+ const refreshed = await refreshRemoteAuthSession({
47
+ accessToken: accessTokenState.value,
48
+ apiBaseUrl,
49
+ refreshToken: refreshTokenState.value,
50
+ });
51
+ return {
52
+ accessToken: refreshed.accessToken,
53
+ apiBaseUrl,
54
+ refreshed: true,
55
+ source: 'config',
56
+ };
57
+ }
58
+ export async function refreshRemoteAuthSession(input) {
59
+ const [apiBaseUrl, accessTokenState, refreshTokenState] = await Promise.all([
60
+ input?.apiBaseUrl ?? requireHostedBaseUrl(),
61
+ input?.accessToken === undefined ? resolveAccessTokenState() : null,
62
+ input?.refreshToken === undefined ? resolveRefreshTokenState() : null,
63
+ ]);
64
+ const accessToken = input?.accessToken === undefined
65
+ ? accessTokenState?.value
66
+ : input.accessToken;
67
+ const refreshToken = input?.refreshToken === undefined
68
+ ? refreshTokenState?.value
69
+ : input.refreshToken;
70
+ if (!refreshToken) {
71
+ throw new Error('Run `postplus auth login` before refreshing PostPlus auth.');
72
+ }
73
+ const response = await fetch(`${apiBaseUrl}/api/postplus-cli/auth/refresh`, {
74
+ method: 'POST',
75
+ headers: {
76
+ accept: 'application/json',
77
+ ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
78
+ 'content-type': 'application/json',
79
+ },
80
+ body: JSON.stringify({
81
+ refreshToken,
82
+ }),
83
+ signal: AbortSignal.timeout(15000),
84
+ });
85
+ const payload = (await response.json());
86
+ if (!response.ok) {
87
+ throw new Error('error' in payload && typeof payload.error === 'string'
88
+ ? payload.error
89
+ : 'Failed to refresh remote PostPlus auth.');
90
+ }
91
+ if (!isRemoteAuthRefreshSuccessPayload(payload)) {
92
+ throw new Error('PostPlus auth refresh returned incomplete session tokens.');
93
+ }
94
+ await setLocalSession({
95
+ accessToken: payload.accessToken,
96
+ accountId: payload.accountId,
97
+ apiBaseUrl,
98
+ refreshToken: payload.refreshToken,
99
+ sessionExpiresAt: payload.sessionExpiresAt,
100
+ userEmail: payload.userEmail,
101
+ userId: payload.userId,
102
+ });
103
+ return {
104
+ ...payload,
105
+ apiBaseUrl,
106
+ };
107
+ }
108
+ export function decodeAccessTokenExpiration(accessToken) {
109
+ try {
110
+ const [, payload] = accessToken.split('.');
111
+ if (!payload) {
112
+ return null;
113
+ }
114
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
115
+ return typeof decoded.exp === 'number' ? decoded.exp : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function isExpiringSoon(expiresAt) {
122
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
123
+ return false;
124
+ }
125
+ const nowSeconds = Math.floor(Date.now() / 1_000);
126
+ return expiresAt - nowSeconds <= AUTH_SESSION_REFRESH_LEEWAY_SECONDS;
127
+ }
128
+ function isRemoteAuthRefreshSuccessPayload(payload) {
129
+ return (typeof payload === 'object' &&
130
+ payload !== null &&
131
+ typeof payload.accessToken === 'string' &&
132
+ payload.accessToken.trim().length > 0 &&
133
+ typeof payload.refreshToken === 'string' &&
134
+ payload.refreshToken.trim().length > 0 &&
135
+ typeof payload.accountId === 'string' &&
136
+ typeof payload.userId === 'string');
137
+ }
@@ -1,21 +1,13 @@
1
- import { requireHostedBaseUrl } from './hosted-release.js';
2
- import { resolveAccessTokenState } from './local-state.js';
1
+ import { resolveFreshRemoteAuth } from './auth-session.js';
3
2
  export async function validateRemoteAuth() {
4
- const [apiBaseUrl, accessTokenState] = await Promise.all([
5
- requireHostedBaseUrl(),
6
- resolveAccessTokenState(),
7
- ]);
8
- if (!accessTokenState.present || !accessTokenState.value) {
9
- throw new Error('Run `postplus auth login` before validating PostPlus auth.');
3
+ let auth = await resolveFreshRemoteAuth();
4
+ let response = await fetchWhoami(auth);
5
+ if (response.status === 401) {
6
+ auth = await resolveFreshRemoteAuth({
7
+ forceRefresh: true,
8
+ });
9
+ response = await fetchWhoami(auth);
10
10
  }
11
- const response = await fetch(`${apiBaseUrl}/api/postplus-cli/auth/whoami`, {
12
- method: 'GET',
13
- headers: {
14
- accept: 'application/json',
15
- authorization: `Bearer ${accessTokenState.value}`,
16
- },
17
- signal: AbortSignal.timeout(15000),
18
- });
19
11
  const payload = (await response.json());
20
12
  if (!response.ok) {
21
13
  throw new Error('error' in payload && typeof payload.error === 'string'
@@ -25,12 +17,9 @@ export async function validateRemoteAuth() {
25
17
  const successPayload = payload;
26
18
  return {
27
19
  accountId: successPayload.accountId,
28
- apiBaseUrl,
20
+ apiBaseUrl: auth.apiBaseUrl,
29
21
  ok: true,
30
- sessionExpiresAt: successPayload.sessionExpiresAt,
31
- source: accessTokenState.source === 'missing'
32
- ? 'config'
33
- : accessTokenState.source,
22
+ source: auth.source,
34
23
  subscriptionStatus: successPayload.subscriptionStatus,
35
24
  userEmail: successPayload.userEmail,
36
25
  userId: successPayload.userId,
@@ -45,8 +34,15 @@ export function formatAuthValidateReport(report) {
45
34
  `Account: ${report.accountId}`,
46
35
  `User: ${report.userEmail ?? report.userId}`,
47
36
  `Subscription: ${report.subscriptionStatus ?? 'unknown'}`,
48
- `Session expires at: ${typeof report.sessionExpiresAt === 'number'
49
- ? new Date(report.sessionExpiresAt * 1000).toISOString()
50
- : 'unknown'}`,
51
37
  ].join('\n');
52
38
  }
39
+ function fetchWhoami(input) {
40
+ return fetch(`${input.apiBaseUrl}/api/postplus-cli/auth/whoami`, {
41
+ method: 'GET',
42
+ headers: {
43
+ accept: 'application/json',
44
+ authorization: `Bearer ${input.accessToken}`,
45
+ },
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ }
package/build/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { clearLocalAuthState, getPostPlusConfigPath, hasLocalConfigFile, maskSecret, readLocalConfig, resolveApiBaseUrlState, resolveLocalSessionState, setLocalAccessToken, setLocalApiBaseUrl, setLocalRefreshToken, } from './local-state.js';
1
+ import { clearLocalAuthState, getPostPlusConfigPath, hasLocalConfigFile, maskSecret, readLocalConfig, resolveApiBaseUrlState, resolveLocalSessionState, setLocalApiBaseUrl, } from './local-state.js';
2
2
  export async function generateAuthStatusReport() {
3
3
  const [sessionState, apiBaseUrlState, configExists, config] = await Promise.all([
4
4
  resolveLocalSessionState(),
@@ -32,7 +32,6 @@ export async function generateAuthStatusReport() {
32
32
  present: sessionState.refreshToken.present,
33
33
  maskedValue: maskSecret(sessionState.refreshToken.value),
34
34
  },
35
- sessionExpiresAt: sessionState.expiresAt,
36
35
  };
37
36
  }
38
37
  export function formatAuthStatusReport(report) {
@@ -60,20 +59,9 @@ export function formatAuthStatusReport(report) {
60
59
  : `[PASS] local config path: ${report.config.path}`);
61
60
  lines.push(` Account: ${report.config.accountId ?? 'not bound'}`);
62
61
  lines.push(` User: ${report.config.userEmail ?? report.config.userId ?? 'not bound'}`);
63
- lines.push(` Session expires at: ${typeof report.sessionExpiresAt === 'number'
64
- ? new Date(report.sessionExpiresAt * 1000).toISOString()
65
- : 'unknown'}`);
66
62
  lines.push('', report.ok ? 'Auth status OK.' : 'Auth status incomplete.');
67
63
  return lines.join('\n');
68
64
  }
69
- export async function configureAccessToken(accessToken) {
70
- await setLocalAccessToken(accessToken);
71
- return generateAuthStatusReport();
72
- }
73
- export async function configureRefreshToken(refreshToken) {
74
- await setLocalRefreshToken(refreshToken);
75
- return generateAuthStatusReport();
76
- }
77
65
  export async function configureApiBaseUrl(apiBaseUrl) {
78
66
  await setLocalApiBaseUrl(apiBaseUrl);
79
67
  return generateAuthStatusReport();
package/build/doctor.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { resolveFreshRemoteAuth, } from './auth-session.js';
1
2
  import { resolveHostedBaseUrl } from './hosted-release.js';
2
- import { resolveAccessTokenState } from './local-state.js';
3
3
  function createPass(id, label, detail) {
4
4
  return {
5
5
  id,
@@ -22,25 +22,24 @@ export async function generateDoctorReport() {
22
22
  const checks = [
23
23
  createPass('hosted_base_url', 'PostPlus Cloud', `Using ${hostedBaseUrl ?? 'https://postplus.io'}`),
24
24
  ];
25
- const accessToken = await resolveAccessTokenState();
26
25
  if (!hostedBaseUrl) {
27
26
  checks.push(createFail('remote_auth', 'Remote auth', 'PostPlus Cloud base URL could not be resolved.', 'Configure POSTPLUS_API_BASE_URL or run `postplus auth login`.'));
28
27
  return buildDoctorReport(checks);
29
28
  }
30
- if (!accessToken.present || !accessToken.value) {
31
- checks.push(createFail('remote_auth', 'Remote auth', 'No PostPlus CLI session is configured.', 'Run `postplus auth login`.'));
29
+ const auth = await resolveFreshRemoteAuth().catch((error) => {
30
+ const message = error instanceof Error
31
+ ? error.message
32
+ : 'No PostPlus CLI session is configured.';
33
+ checks.push(createFail('remote_auth', 'Remote auth', message, 'Run `postplus auth login`.'));
34
+ return null;
35
+ });
36
+ if (!auth) {
32
37
  return buildDoctorReport(checks);
33
38
  }
34
- const authCheck = await checkRemoteAuth({
35
- accessToken: accessToken.value,
36
- hostedBaseUrl,
37
- });
39
+ const authCheck = await checkRemoteAuth(auth);
38
40
  checks.push(authCheck);
39
41
  if (authCheck.status === 'pass') {
40
- checks.push(await checkHostedCapabilities({
41
- accessToken: accessToken.value,
42
- hostedBaseUrl,
43
- }));
42
+ checks.push(await checkHostedCapabilities(auth));
44
43
  }
45
44
  return buildDoctorReport(checks);
46
45
  }
@@ -52,13 +51,13 @@ function buildDoctorReport(checks) {
52
51
  }
53
52
  async function checkRemoteAuth(input) {
54
53
  try {
55
- const response = await fetch(`${input.hostedBaseUrl}/api/postplus-cli/auth/whoami`, {
56
- headers: {
57
- accept: 'application/json',
58
- authorization: `Bearer ${input.accessToken}`,
59
- },
60
- signal: AbortSignal.timeout(15000),
61
- });
54
+ let response = await requestWithAuth(input, '/api/postplus-cli/auth/whoami');
55
+ if (response.status === 401) {
56
+ const refreshedAuth = await resolveFreshRemoteAuth({
57
+ forceRefresh: true,
58
+ });
59
+ response = await requestWithAuth(refreshedAuth, '/api/postplus-cli/auth/whoami');
60
+ }
62
61
  const payload = (await response.json());
63
62
  if (!response.ok) {
64
63
  return createFail('remote_auth', 'Remote auth', readErrorMessage(payload, 'PostPlus Cloud rejected the CLI session.'), 'Run `postplus auth login`.');
@@ -82,13 +81,13 @@ async function checkRemoteAuth(input) {
82
81
  }
83
82
  async function checkHostedCapabilities(input) {
84
83
  try {
85
- const response = await fetch(`${input.hostedBaseUrl}/api/postplus-cli/hosted/readiness`, {
86
- headers: {
87
- accept: 'application/json',
88
- authorization: `Bearer ${input.accessToken}`,
89
- },
90
- signal: AbortSignal.timeout(15000),
91
- });
84
+ let response = await requestWithAuth(input, '/api/postplus-cli/hosted/readiness');
85
+ if (response.status === 401) {
86
+ const refreshedAuth = await resolveFreshRemoteAuth({
87
+ forceRefresh: true,
88
+ });
89
+ response = await requestWithAuth(refreshedAuth, '/api/postplus-cli/hosted/readiness');
90
+ }
92
91
  const payload = (await response.json());
93
92
  if (!response.ok) {
94
93
  return createFail('hosted_capabilities', 'Hosted capabilities', readErrorMessage(payload, 'PostPlus Cloud hosted readiness check failed.'));
@@ -132,6 +131,15 @@ function readErrorMessage(payload, fallback) {
132
131
  ? payload.error
133
132
  : fallback;
134
133
  }
134
+ function requestWithAuth(input, path) {
135
+ return fetch(`${input.apiBaseUrl}${path}`, {
136
+ headers: {
137
+ accept: 'application/json',
138
+ authorization: `Bearer ${input.accessToken}`,
139
+ },
140
+ signal: AbortSignal.timeout(15000),
141
+ });
142
+ }
135
143
  export function formatDoctorReport(report) {
136
144
  const lines = ['PostPlus CLI doctor', ''];
137
145
  for (const check of report.checks) {
package/build/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { formatAuthRefreshReport, refreshRemoteAuth, revokeRemoteAuthAndReport, } from './auth-lifecycle.js';
3
- import { loginWithBrowserHandoff } from './auth-login.js';
3
+ import { loginWithCloudHandoff } from './auth-login.js';
4
4
  import { formatAuthValidateReport, validateRemoteAuth, } from './auth-validate.js';
5
5
  import { clearAuthState, formatAuthStatusReport, generateAuthStatusReport, } from './auth.js';
6
- import { formatDoctorReport, generateDoctorReport, } from './doctor.js';
6
+ import { formatDoctorReport, generateDoctorReport } from './doctor.js';
7
7
  import { assertConfigFilePermissions } from './local-state.js';
8
8
  import { POSTPLUS_SKILLS_INSTALL_COMMAND, loadPublicSkillCatalog, } from './skill-catalog.js';
9
9
  import { runPostPlusSkillUninstall, runPostPlusSkillUpdate, } from './skill-management.js';
@@ -140,16 +140,13 @@ async function runAuthRevoke(json) {
140
140
  return 0;
141
141
  }
142
142
  async function runAuthLogin() {
143
- const report = await loginWithBrowserHandoff();
143
+ const report = await loginWithCloudHandoff();
144
144
  process.stdout.write([
145
145
  '',
146
146
  'PostPlus CLI login complete.',
147
147
  `Account: ${report.accountId}`,
148
148
  `PostPlus Cloud: ${report.apiBaseUrl}`,
149
149
  `User: ${report.userEmail ?? 'unknown'}`,
150
- `Session expires at: ${typeof report.sessionExpiresAt === 'number'
151
- ? new Date(report.sessionExpiresAt * 1000).toISOString()
152
- : 'unknown'}`,
153
150
  '',
154
151
  ].join('\n'));
155
152
  return report.ok ? 0 : 1;
@@ -1,5 +1,5 @@
1
1
  import { constants as fsConstants } from 'node:fs';
2
- import { access, chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { access, chmod, mkdir, readFile, stat, writeFile, } from 'node:fs/promises';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  export const DEFAULT_POSTPLUS_API_BASE_URL = 'https://postplus.io';
@@ -103,26 +103,6 @@ export async function clearLocalAuthState() {
103
103
  return next;
104
104
  });
105
105
  }
106
- export async function setLocalAccessToken(accessToken) {
107
- const normalizedAccessToken = accessToken.trim();
108
- if (normalizedAccessToken.length === 0) {
109
- throw new Error('POSTPLUS_ACCESS_TOKEN cannot be empty.');
110
- }
111
- return updateLocalConfig((current) => ({
112
- ...(current ?? {}),
113
- accessToken: normalizedAccessToken,
114
- }));
115
- }
116
- export async function setLocalRefreshToken(refreshToken) {
117
- const normalizedRefreshToken = refreshToken.trim();
118
- if (normalizedRefreshToken.length === 0) {
119
- throw new Error('POSTPLUS_REFRESH_TOKEN cannot be empty.');
120
- }
121
- return updateLocalConfig((current) => ({
122
- ...(current ?? {}),
123
- refreshToken: normalizedRefreshToken,
124
- }));
125
- }
126
106
  export async function setLocalApiBaseUrl(apiBaseUrl) {
127
107
  const normalizedApiBaseUrl = apiBaseUrl.trim();
128
108
  if (normalizedApiBaseUrl.length === 0) {
@@ -139,10 +119,10 @@ export async function setLocalSession(input) {
139
119
  const refreshToken = input.refreshToken.trim();
140
120
  const apiBaseUrl = input.apiBaseUrl.trim().replace(/\/+$/, '');
141
121
  if (accessToken.length === 0) {
142
- throw new Error('POSTPLUS_ACCESS_TOKEN cannot be empty.');
122
+ throw new Error('PostPlus CLI access token cannot be empty.');
143
123
  }
144
124
  if (refreshToken.length === 0) {
145
- throw new Error('POSTPLUS_REFRESH_TOKEN cannot be empty.');
125
+ throw new Error('PostPlus CLI refresh token cannot be empty.');
146
126
  }
147
127
  if (apiBaseUrl.length === 0) {
148
128
  throw new Error('POSTPLUS_API_BASE_URL cannot be empty.');
@@ -168,14 +148,6 @@ export async function hasLocalConfigFile() {
168
148
  }
169
149
  }
170
150
  export async function resolveAccessTokenState() {
171
- const envValue = process.env.POSTPLUS_ACCESS_TOKEN?.trim();
172
- if (envValue && envValue.length > 0) {
173
- return {
174
- source: 'env',
175
- present: true,
176
- value: envValue,
177
- };
178
- }
179
151
  const config = await readLocalConfig();
180
152
  const configValue = config?.accessToken?.trim();
181
153
  if (configValue && configValue.length > 0) {
@@ -192,14 +164,6 @@ export async function resolveAccessTokenState() {
192
164
  };
193
165
  }
194
166
  export async function resolveRefreshTokenState() {
195
- const envValue = process.env.POSTPLUS_REFRESH_TOKEN?.trim();
196
- if (envValue && envValue.length > 0) {
197
- return {
198
- source: 'env',
199
- present: true,
200
- value: envValue,
201
- };
202
- }
203
167
  const config = await readLocalConfig();
204
168
  const configValue = config?.refreshToken?.trim();
205
169
  if (configValue && configValue.length > 0) {
@@ -216,16 +180,12 @@ export async function resolveRefreshTokenState() {
216
180
  };
217
181
  }
218
182
  export async function resolveLocalSessionState() {
219
- const [accessToken, refreshToken, config] = await Promise.all([
183
+ const [accessToken, refreshToken] = await Promise.all([
220
184
  resolveAccessTokenState(),
221
185
  resolveRefreshTokenState(),
222
- readLocalConfig(),
223
186
  ]);
224
187
  return {
225
188
  accessToken,
226
- expiresAt: typeof config?.sessionExpiresAt === 'number'
227
- ? config.sessionExpiresAt
228
- : null,
229
189
  refreshToken,
230
190
  };
231
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postplus/cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
5
5
  "type": "module",
6
6
  "description": "PostPlus CLI for PostPlus Cloud auth, status, and diagnostics.",
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "build/auth-lifecycle.js",
10
10
  "build/auth-login.js",
11
+ "build/auth-session.js",
11
12
  "build/auth-validate.js",
12
13
  "build/auth.js",
13
14
  "build/command-runner.js",