Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc11bf4910 | |||
| cc82666b9a |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user