Compare commits

...

2 Commits

Author SHA1 Message Date
jackwener cc11bf4910 test(auth): make scheduler due test timezone stable 2026-06-12 19:34:40 +08:00
jackwener cc82666b9a feat(auth): add app scheduled refresh 2026-06-12 19:26:55 +08:00
5 changed files with 562 additions and 5 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
### Features
* **auth** — add `opencli auth refresh-scheduled` for OpenCLI App's App-alive daily auth refresh MVP. The command reads the App-owned `auth-refresh-config.json`, writes the core-owned `auth-refresh-state.json`, applies per-site schedule jitter and simple backoff, and reuses the existing `auth refresh` primitive without mixing with the manual `~/.opencli/auth-refresh.json` state.
## [1.8.3](https://github.com/jackwener/opencli/compare/v1.8.2...v1.8.3) (2026-06-06)
Patch release focused on two architectural fixes around extension and daemon lifecycle, plus the first wave of the new site auth subsystem.
+70
View File
@@ -0,0 +1,70 @@
# Auth Refresh Scheduler
This document defines the first OpenCLI App integration for automatic auth/session keepalive.
## Scope
The first MVP is App-alive only: the App stays running in the background and invokes a short-lived core command around the configured daily schedule. App quit, machine sleep, offline recovery, and launchd catch-up are intentionally out of scope for this phase.
The primitive is auth refresh/touch, not daily `whoami`:
- `whoami` verifies identity and can be slow or navigation-heavy.
- `auth refresh` tries an explicit adapter refresh hook first.
- If no explicit hook exists, it touches the site origin and verifies with `quickCheck`.
- Sites without either capability are marked `unsupported`.
## Files
OpenCLI App owns the config file:
```text
~/Library/Application Support/OpenCLI App/auth-refresh-config.json
```
OpenCLI core owns the run-state file:
```text
~/Library/Application Support/OpenCLI App/auth-refresh-state.json
```
The scheduler command reads config and writes run state. The App should only read run state.
## Command
```bash
opencli auth refresh-scheduled
```
Options:
- `--site <sites>` limits the scheduler to a comma-separated site set.
- `--all` ignores schedule and backoff, but still respects enabled/disabled config.
- `--timeout <seconds>` sets the per-site refresh timeout.
- `--config-path <path>` and `--run-state-path <path>` are for App tests and local smoke tests.
- `--jitter-minutes <minutes>` controls stable per-site schedule jitter; default is 120.
## Due Calculation
For each browser-backed `whoami` command:
1. The site must be enabled by App config. A per-site override wins; otherwise the global switch applies.
2. `unsupported` sites are skipped after first detection.
3. Sites with `consecutiveFailures >= 3` are skipped until forced or reset by App/user action.
4. The site is due when its last attempt is older than today's scheduled time plus stable per-site jitter.
The command processes due sites serially and exits. It does not sleep for hours.
## State Semantics
Run-state timestamps use the App schema format: `@<unix-seconds>`.
Per-site statuses:
- `touched`: origin touch plus verification succeeded.
- `refreshed`: explicit adapter refresh hook succeeded.
- `not_logged_in`: session is missing or expired; user must run login.
- `error`: transient or unexpected failure.
- `unsupported`: no refresh/touch capability exists for the site.
- `pending`: reserved for App display when no attempt has run yet.
`lastFullRun` records the scheduler invocation time. `lastFullRunSummary` is a short human-readable status count for Settings UI.
+3 -3
View File
@@ -66,7 +66,7 @@ describe('createProgram root help descriptions', () => {
expect(descriptionFor(program, 'browser')).toContain('type');
expect(descriptionFor(program, 'browser')).toContain('verify');
expect(descriptionFor(program, 'browser')).not.toContain('Browser control');
expect(descriptionFor(program, 'auth')).toBe('refresh, status');
expect(descriptionFor(program, 'auth')).toBe('refresh, refresh-scheduled, status');
expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
@@ -87,9 +87,9 @@ describe('createProgram root help descriptions', () => {
expect(data).toMatchObject({
namespace: 'auth',
description: 'Inspect website login status',
command_count: 2,
command_count: 3,
});
expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['refresh', 'status']);
expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['refresh', 'refresh-scheduled', 'status']);
const status = auth.commands.find(cmd => cmd.name() === 'status')!;
process.argv = ['node', 'opencli', 'auth', 'status', '--help', '-f', 'yaml'];
const statusData = yaml.load(status.helpInformation()) as any;
+172 -2
View File
@@ -1,4 +1,4 @@
import { mkdtemp, readFile } from 'node:fs/promises';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -10,7 +10,7 @@ vi.mock('../execution.js', () => ({
executeCommand: executeCommandMock,
}));
import { collectAuthRefresh, collectAuthStatus } from './auth.js';
import { collectAuthRefresh, collectAuthRefreshScheduled, collectAuthStatus } from './auth.js';
import { AuthRequiredError } from '../errors.js';
import { cli, getRegistry, Strategy } from '../registry.js';
@@ -44,6 +44,18 @@ async function tempStatePath(): Promise<string> {
return join(dir, 'auth-refresh.json');
}
async function tempAppAuthRefreshPaths(): Promise<{ configPath: string; runStatePath: string }> {
const dir = await mkdtemp(join(tmpdir(), 'opencli-app-auth-refresh-test-'));
return {
configPath: join(dir, 'auth-refresh-config.json'),
runStatePath: join(dir, 'auth-refresh-state.json'),
};
}
async function writeJson(path: string, value: unknown): Promise<void> {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
beforeEach(() => {
getRegistry().clear();
executeCommandMock.mockReset();
@@ -295,3 +307,161 @@ describe('auth refresh collection', () => {
expect(executeCommandMock).not.toHaveBeenCalled();
});
});
describe('auth refresh scheduled collection', () => {
it('does nothing when the App auth refresh config is disabled', async () => {
registerWhoami('alpha', { quick: true, quickLoggedIn: true });
const { configPath, runStatePath } = await tempAppAuthRefreshPaths();
await writeJson(configPath, { enabled: false, scheduleTime: '03:00', perSiteEnabled: {} });
const rows = await collectAuthRefreshScheduled({
configPath,
runStatePath,
now: new Date('2026-06-06T04:00:00.000Z'),
});
expect(rows).toEqual([]);
expect(executeCommandMock).not.toHaveBeenCalled();
const state = JSON.parse(await readFile(runStatePath, 'utf8'));
expect(state.lastFullRun).toBe('@1780718400');
expect(state.lastFullRunSummary).toBe('No due sites');
});
it('runs due enabled sites and writes App-shaped run state timestamps', async () => {
registerWhoami('alpha', { quick: true, quickLoggedIn: true });
const { configPath, runStatePath } = await tempAppAuthRefreshPaths();
await writeJson(configPath, { enabled: true, scheduleTime: '03:00', perSiteEnabled: {} });
const now = new Date('2026-06-06T04:00:00.000Z');
const rows = await collectAuthRefreshScheduled({
sites: 'alpha',
configPath,
runStatePath,
now,
jitterMinutes: 1,
});
expect(rows).toEqual([
{
site: 'alpha',
status: 'touched',
lastAttempt: '@1780718400',
lastTouched: '@1780718400',
message: '',
},
]);
expect(executeCommandMock).toHaveBeenCalledTimes(1);
const state = JSON.parse(await readFile(runStatePath, 'utf8'));
expect(state).toMatchObject({
schemaVersion: 1,
lastFullRun: '@1780718400',
lastFullRunSummary: '1 touched',
perSite: {
alpha: {
lastAttempt: '@1780718400',
lastTouched: '@1780718400',
status: 'touched',
consecutiveFailures: 0,
},
},
});
});
it('skips sites already attempted in the current due window', async () => {
registerWhoami('beta', { quick: true, quickLoggedIn: true });
const { configPath, runStatePath } = await tempAppAuthRefreshPaths();
await writeJson(configPath, { enabled: true, scheduleTime: '03:00', perSiteEnabled: {} });
await writeJson(runStatePath, {
schemaVersion: 1,
perSite: {
beta: { lastAttempt: '@1780718400', status: 'touched', consecutiveFailures: 0 },
},
});
const rows = await collectAuthRefreshScheduled({
sites: 'beta',
configPath,
runStatePath,
now: new Date('2026-06-06T04:00:00.000Z'),
jitterMinutes: 1,
});
expect(rows).toEqual([]);
expect(executeCommandMock).not.toHaveBeenCalled();
});
it('honors per-site disabled overrides and failure backoff', async () => {
registerWhoami('alpha', { quick: true, quickLoggedIn: true });
registerWhoami('beta', { quick: true, quickLoggedIn: true });
registerWhoami('gamma', { quick: true, quickLoggedIn: true });
const { configPath, runStatePath } = await tempAppAuthRefreshPaths();
await writeJson(configPath, {
enabled: true,
scheduleTime: '03:00',
perSiteEnabled: { beta: false },
});
await writeJson(runStatePath, {
schemaVersion: 1,
perSite: {
gamma: { status: 'error', consecutiveFailures: 3, lastAttempt: '@1780632000' },
},
});
const rows = await collectAuthRefreshScheduled({
configPath,
runStatePath,
now: new Date('2026-06-06T04:00:00.000Z'),
jitterMinutes: 1,
});
expect(rows.map(row => row.site)).toEqual(['alpha']);
expect(executeCommandMock).toHaveBeenCalledTimes(1);
});
it('records unsupported sites in App run state', async () => {
registerWhoami('eta');
const { configPath, runStatePath } = await tempAppAuthRefreshPaths();
await writeJson(configPath, { enabled: true, scheduleTime: '03:00', perSiteEnabled: {} });
const rows = await collectAuthRefreshScheduled({
sites: 'eta',
configPath,
runStatePath,
now: new Date('2026-06-06T04:00:00.000Z'),
jitterMinutes: 1,
});
expect(rows).toEqual([
{
site: 'eta',
status: 'unsupported',
lastAttempt: '@1780718400',
lastTouched: '',
message: 'refresh probe is not available for this site',
},
]);
const state = JSON.parse(await readFile(runStatePath, 'utf8'));
expect(state.perSite.eta).toMatchObject({
lastAttempt: '@1780718400',
status: 'unsupported',
message: 'refresh probe is not available for this site',
consecutiveFailures: 0,
});
});
it('allows zero jitter for deterministic scheduler debugging', async () => {
registerWhoami('theta', { quick: true, quickLoggedIn: true });
const { configPath, runStatePath } = await tempAppAuthRefreshPaths();
await writeJson(configPath, { enabled: true, scheduleTime: '03:00', perSiteEnabled: {} });
const rows = await collectAuthRefreshScheduled({
sites: 'theta',
configPath,
runStatePath,
now: new Date('2026-06-06T03:00:00.000Z'),
jitterMinutes: 0,
});
expect(rows[0]?.status).toBe('touched');
});
});
+311
View File
@@ -18,9 +18,13 @@ import { render as renderOutput } from '../output.js';
type AuthStatus = 'logged_in' | 'not_logged_in' | 'unknown' | 'error';
type AuthStatusMode = 'quick' | 'full';
type AuthRefreshStatus = 'refreshed' | 'touched' | 'not_logged_in' | 'skipped' | 'unsupported' | 'error';
type AppAuthRefreshStatus = 'pending' | 'touched' | 'refreshed' | 'not_logged_in' | 'error' | 'unsupported';
const AUTH_REFRESH_STATE_VERSION = 1;
const AUTH_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const APP_AUTH_REFRESH_SCHEMA_VERSION = 1;
const APP_AUTH_REFRESH_FAILURE_LIMIT = 3;
const APP_AUTH_REFRESH_DEFAULT_JITTER_MINUTES = 120;
export interface AuthStatusRow {
site: string;
@@ -58,6 +62,17 @@ interface AuthRefreshOptions {
now?: Date;
}
interface AuthRefreshScheduledOptions {
sites?: string;
all?: boolean;
timeout?: string | number;
profile?: string;
configPath?: string;
runStatePath?: string;
now?: Date;
jitterMinutes?: string | number;
}
interface AuthRefreshSiteState {
last_touched_at?: string;
last_attempt_at?: string;
@@ -69,6 +84,36 @@ interface AuthRefreshState {
sites: Record<string, AuthRefreshSiteState>;
}
interface AppAuthRefreshConfig {
schemaVersion?: number;
enabled?: boolean;
scheduleTime?: string;
perSiteEnabled?: Record<string, boolean>;
}
interface AppAuthRefreshSiteEntry {
lastAttempt?: string;
lastTouched?: string;
status?: AppAuthRefreshStatus;
message?: string;
consecutiveFailures?: number;
}
interface AppAuthRefreshRunState {
schemaVersion?: number;
lastFullRun?: string;
lastFullRunSummary?: string;
perSite?: Record<string, AppAuthRefreshSiteEntry>;
}
export interface AppAuthRefreshScheduledRow {
site: string;
status: AppAuthRefreshStatus | 'skipped';
lastAttempt: string;
lastTouched: string;
message: string;
}
function parsePositiveInt(raw: string | number | undefined, label: string, fallback: number): number {
if (raw === undefined || raw === null || raw === '') return fallback;
const parsed = Number(raw);
@@ -78,6 +123,15 @@ function parsePositiveInt(raw: string | number | undefined, label: string, fallb
return parsed;
}
function parseNonNegativeInt(raw: string | number | undefined, label: string, fallback: number): number {
if (raw === undefined || raw === null || raw === '') return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new InvalidArgumentError(`${label} must be a non-negative integer. Received: "${String(raw)}"`);
}
return parsed;
}
function parseSiteFilter(raw: string | undefined): Set<string> | null {
if (!raw || !raw.trim()) return null;
const sites = raw.split(',').map(site => site.trim()).filter(Boolean);
@@ -88,6 +142,18 @@ function defaultAuthRefreshStatePath(): string {
return join(homedir(), '.opencli', 'auth-refresh.json');
}
function openCliAppSupportDir(): string {
return join(homedir(), 'Library', 'Application Support', 'OpenCLI App');
}
function defaultAppAuthRefreshConfigPath(): string {
return join(openCliAppSupportDir(), 'auth-refresh-config.json');
}
function defaultAppAuthRefreshRunStatePath(): string {
return join(openCliAppSupportDir(), 'auth-refresh-state.json');
}
function emptyAuthRefreshState(): AuthRefreshState {
return { version: AUTH_REFRESH_STATE_VERSION, sites: {} };
}
@@ -109,12 +175,70 @@ async function saveAuthRefreshState(statePath: string, state: AuthRefreshState):
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
async function loadAppAuthRefreshConfig(configPath: string): Promise<AppAuthRefreshConfig> {
try {
const parsed = JSON.parse(await readFile(configPath, 'utf8')) as Partial<AppAuthRefreshConfig>;
return {
schemaVersion: APP_AUTH_REFRESH_SCHEMA_VERSION,
enabled: parsed.enabled === true,
scheduleTime: typeof parsed.scheduleTime === 'string' ? parsed.scheduleTime : '03:00',
perSiteEnabled: parsed.perSiteEnabled && typeof parsed.perSiteEnabled === 'object'
? parsed.perSiteEnabled as Record<string, boolean>
: {},
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
return { schemaVersion: APP_AUTH_REFRESH_SCHEMA_VERSION, enabled: false, scheduleTime: '03:00', perSiteEnabled: {} };
}
async function loadAppAuthRefreshRunState(runStatePath: string): Promise<AppAuthRefreshRunState> {
try {
const parsed = JSON.parse(await readFile(runStatePath, 'utf8')) as Partial<AppAuthRefreshRunState>;
return {
schemaVersion: APP_AUTH_REFRESH_SCHEMA_VERSION,
lastFullRun: typeof parsed.lastFullRun === 'string' ? parsed.lastFullRun : undefined,
lastFullRunSummary: typeof parsed.lastFullRunSummary === 'string' ? parsed.lastFullRunSummary : undefined,
perSite: parsed.perSite && typeof parsed.perSite === 'object'
? parsed.perSite as Record<string, AppAuthRefreshSiteEntry>
: {},
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
return { schemaVersion: APP_AUTH_REFRESH_SCHEMA_VERSION, perSite: {} };
}
async function saveAppAuthRefreshRunState(runStatePath: string, state: AppAuthRefreshRunState): Promise<void> {
await mkdir(dirname(runStatePath), { recursive: true });
await writeFile(runStatePath, `${JSON.stringify({
schemaVersion: APP_AUTH_REFRESH_SCHEMA_VERSION,
lastFullRun: state.lastFullRun,
lastFullRunSummary: state.lastFullRunSummary,
perSite: state.perSite ?? {},
}, null, 2)}\n`, 'utf8');
}
function lastTouchedMs(entry: AuthRefreshSiteState | undefined): number | null {
if (!entry?.last_touched_at) return null;
const parsed = Date.parse(entry.last_touched_at);
return Number.isFinite(parsed) ? parsed : null;
}
function parseAppTimestamp(value: string | undefined): number | null {
if (!value) return null;
if (value.startsWith('@')) {
const parsed = Number(value.slice(1));
return Number.isFinite(parsed) ? parsed * 1000 : null;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatAppTimestamp(date: Date): string {
return `@${Math.floor(date.getTime() / 1000)}`;
}
function isRefreshThrottled(entry: AuthRefreshSiteState | undefined, now: Date): boolean {
const touched = lastTouchedMs(entry);
return touched !== null && now.getTime() - touched < AUTH_REFRESH_INTERVAL_MS;
@@ -125,6 +249,104 @@ function nextRefreshAt(entry: AuthRefreshSiteState | undefined): string {
return touched === null ? '' : new Date(touched + AUTH_REFRESH_INTERVAL_MS).toISOString();
}
function parseScheduleTime(raw: string | undefined): { hour: number; minute: number } {
const match = /^(\d{2}):(\d{2})$/.exec(raw ?? '03:00');
if (!match) return { hour: 3, minute: 0 };
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return { hour: 3, minute: 0 };
}
return { hour, minute };
}
function stableSiteJitterMs(site: string, maxMinutes: number): number {
if (maxMinutes <= 0) return 0;
let hash = 2166136261;
for (const char of site) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
const minutes = Math.abs(hash >>> 0) % (maxMinutes + 1);
return minutes * 60 * 1000;
}
function scheduledDueAt(site: string, scheduleTime: string | undefined, now: Date, jitterMinutes: number): number {
const { hour, minute } = parseScheduleTime(scheduleTime);
const candidate = new Date(now);
candidate.setHours(hour, minute, 0, 0);
candidate.setTime(candidate.getTime() + stableSiteJitterMs(site, jitterMinutes));
if (candidate.getTime() > now.getTime()) {
candidate.setDate(candidate.getDate() - 1);
}
return candidate.getTime();
}
function appSiteEnabled(config: AppAuthRefreshConfig, site: string): boolean {
const override = config.perSiteEnabled?.[site];
return typeof override === 'boolean' ? override : config.enabled === true;
}
function appEntryToRefreshState(entry: AppAuthRefreshSiteEntry | undefined): AuthRefreshSiteState | undefined {
if (!entry) return undefined;
const lastTouched = parseAppTimestamp(entry.lastTouched);
const lastAttempt = parseAppTimestamp(entry.lastAttempt);
return {
...(lastTouched !== null ? { last_touched_at: new Date(lastTouched).toISOString() } : {}),
...(lastAttempt !== null ? { last_attempt_at: new Date(lastAttempt).toISOString() } : {}),
...(entry.status ? { last_status: entry.status === 'pending' ? undefined : entry.status } : {}),
};
}
function shouldRunScheduledSite(
site: string,
entry: AppAuthRefreshSiteEntry | undefined,
config: AppAuthRefreshConfig,
now: Date,
jitterMinutes: number,
force: boolean,
): boolean {
if (force) return true;
if (entry?.status === 'unsupported') return false;
if ((entry?.consecutiveFailures ?? 0) >= APP_AUTH_REFRESH_FAILURE_LIMIT) return false;
const lastAttempt = parseAppTimestamp(entry?.lastAttempt);
if (lastAttempt === null) return true;
return lastAttempt < scheduledDueAt(site, config.scheduleTime, now, jitterMinutes);
}
function updateAppRunStateFromRefreshRow(
runState: AppAuthRefreshRunState,
row: AuthRefreshRow,
now: Date,
): AppAuthRefreshSiteEntry {
const previous = runState.perSite?.[row.site] ?? {};
const lastAttempt = formatAppTimestamp(now);
const success = row.status === 'touched' || row.status === 'refreshed';
const status: AppAuthRefreshStatus = row.status === 'skipped' ? 'pending' : row.status;
const next: AppAuthRefreshSiteEntry = {
...previous,
lastAttempt,
status,
message: row.error || undefined,
consecutiveFailures: row.status === 'error' ? (previous.consecutiveFailures ?? 0) + 1 : 0,
};
if (success) {
next.lastTouched = lastAttempt;
}
if (row.status === 'not_logged_in' || row.status === 'unsupported') {
next.consecutiveFailures = 0;
}
runState.perSite = { ...(runState.perSite ?? {}), [row.site]: next };
return next;
}
function scheduledSummary(rows: AppAuthRefreshScheduledRow[]): string {
if (rows.length === 0) return 'No due sites';
const counts = new Map<string, number>();
for (const row of rows) counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
return [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(', ');
}
function authWhoamiCommands(): CliCommand[] {
const seen = new Set<CliCommand>();
return [...getRegistry().values()]
@@ -453,6 +675,64 @@ export async function collectAuthRefresh(options: AuthRefreshOptions): Promise<A
return rows;
}
export async function collectAuthRefreshScheduled(options: AuthRefreshScheduledOptions): Promise<AppAuthRefreshScheduledRow[]> {
const selectedSites = parseSiteFilter(options.sites);
const timeoutSeconds = parsePositiveInt(options.timeout, '--timeout', 20);
const jitterMinutes = parseNonNegativeInt(options.jitterMinutes, '--jitter-minutes', APP_AUTH_REFRESH_DEFAULT_JITTER_MINUTES);
const now = options.now ?? new Date();
const configPath = options.configPath ?? defaultAppAuthRefreshConfigPath();
const runStatePath = options.runStatePath ?? defaultAppAuthRefreshRunStatePath();
const config = await loadAppAuthRefreshConfig(configPath);
const runState = await loadAppAuthRefreshRunState(runStatePath);
runState.perSite = runState.perSite ?? {};
const dueCommands = authWhoamiCommands()
.filter(cmd => !selectedSites || selectedSites.has(cmd.site))
.filter(cmd => appSiteEnabled(config, cmd.site))
.filter(cmd => shouldRunScheduledSite(
cmd.site,
runState.perSite?.[cmd.site],
config,
now,
jitterMinutes,
options.all === true,
));
const rows: AppAuthRefreshScheduledRow[] = [];
const refreshState: AuthRefreshState = {
version: AUTH_REFRESH_STATE_VERSION,
sites: Object.fromEntries(
Object.entries(runState.perSite ?? {}).flatMap(([site, entry]) => {
const state = appEntryToRefreshState(entry);
return state ? [[site, state]] : [];
}),
),
};
for (const cmd of dueCommands) {
const [row] = await mapConcurrent([cmd], 1, item => runRefresh(item, {
timeoutSeconds,
profile: options.profile,
now,
state: refreshState,
force: true,
}));
const next = updateAppRunStateFromRefreshRow(runState, row, now);
rows.push({
site: row.site,
status: next.status ?? 'pending',
lastAttempt: next.lastAttempt ?? '',
lastTouched: next.lastTouched ?? '',
message: next.message ?? '',
});
}
runState.lastFullRun = formatAppTimestamp(now);
runState.lastFullRunSummary = scheduledSummary(rows);
await saveAppAuthRefreshRunState(runStatePath, runState);
return rows;
}
export function registerAuthCommands(program: Command): Command {
const auth = program
.command('auth')
@@ -514,5 +794,36 @@ export function registerAuthCommands(program: Command): Command {
});
});
const refreshScheduled = auth
.command('refresh-scheduled')
.description('Run due App-scheduled auth refresh jobs')
.option('--site <sites>', 'Comma-separated site names to consider')
.option('--all', 'Ignore schedule/backoff and run every enabled selected site', false)
.option('--timeout <seconds>', 'Per-site timeout in seconds')
.option('--config-path <path>', 'Path to OpenCLI App auth-refresh-config.json')
.option('--run-state-path <path>', 'Path to OpenCLI App auth-refresh-state.json')
.option('--jitter-minutes <minutes>', 'Maximum stable per-site schedule jitter in minutes')
.option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
.action(async (opts) => {
const globals = typeof refreshScheduled.optsWithGlobals === 'function' ? refreshScheduled.optsWithGlobals() as Record<string, unknown> : {};
const rows = await collectAuthRefreshScheduled({
sites: opts.site,
all: opts.all === true,
timeout: opts.timeout,
configPath: typeof opts.configPath === 'string' ? opts.configPath : undefined,
runStatePath: typeof opts.runStatePath === 'string' ? opts.runStatePath : undefined,
jitterMinutes: opts.jitterMinutes,
profile: typeof globals.profile === 'string' && globals.profile.trim() ? globals.profile.trim() : undefined,
});
const fmt = typeof opts.format === 'string' ? opts.format : 'table';
renderOutput(rows, {
fmt,
fmtExplicit: refreshScheduled.getOptionValueSource('format') === 'cli',
columns: ['site', 'status', 'lastAttempt', 'lastTouched', 'message'],
title: 'opencli/auth refresh-scheduled',
source: opts.all ? 'forced app scheduler' : 'app scheduler due sites',
});
});
return auth;
}