Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ POLYLANE_VERSION=v0.1.0 curl -fsSL https://polylane.com/install.sh | bash
$env:POLYLANE_VERSION='v0.1.0'; irm https://polylane.com/install.ps1 | iex
```

The CLI checks for updates after successful commands, at most once per day,
and updates the active install without a prompt. npm, Bun, and Homebrew installs
use their own package manager. Standalone installs verify the release checksum
and version before replacing the bundle. The current command finishes on its
original version; the next command uses the update. Progress goes to stderr.

Run `polylane update` to update now, or `polylane update --dry-run` to inspect
the detected install and available update. Set `POLYLANE_NO_AUTO_UPDATE=1` to
disable automatic updates. Keep `POLYLANE_VERSION` set when running the CLI to
retain a version pin. Homebrew pins are also respected. CI, development,
prerelease, and unrecognized installs do not update automatically. An automatic
update failure does not change the command's result; `polylane update` reports
update errors and exits nonzero.

### Wire it into your coding agents

```bash
Expand Down
2 changes: 2 additions & 0 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ async function main(): Promise<void> {
'POLYLANE_ONBOARDING_RUN',
'POLYLANE_TELEMETRY_NOTICE_ACK',
'POLYLANE_TERMS_NOTICE_ACK',
'POLYLANE_NO_AUTO_UPDATE',
'POLYLANE_VERSION',
]);
// Bake every other POLYLANE_* env var visible at build time into the bundle, so
// the produced binary works without needing those vars set at runtime.
Expand Down
7 changes: 7 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ polylane auth signup --email <email> --code <code> # finish signup with the
polylane auth status
```

The CLI updates its active install after successful commands, at most once
per day, without a prompt. Run `polylane update` to update now or add
`--dry-run` to inspect the update. Set `POLYLANE_NO_AUTO_UPDATE=1` to disable
automatic updates. Keep `POLYLANE_VERSION` set to retain a version pin.
CI, development, prerelease, and unknown installs do not update automatically.
The next command uses the updated version; stdout stays pure data.

**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** generates a strong random password when `--password` is omitted and prints it once on stderr (also `generated_password` in JSON output); store it, or change it later via password reset. Weak or known-leaked passwords are challenged at the edge (exit `2`, "challenged by the edge, usually because the password is weak or known-leaked"): do not invent one, let the CLI generate it. Signup emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it.

Account-lifecycle operations beyond signup/login (reset password, update profile, delete account, notification settings) live in the web console. Reach them from the CLI via `polylane api call <op>` if you must.
Expand Down
82 changes: 4 additions & 78 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,10 @@
import type { Command } from '../command';
import type { Config } from '../config/schema';
import { UPDATE_STATE_FILE } from '../config/paths';
import { readJsonFile, writeJsonFile } from '../utils/fs';
import { isCI } from '../utils/env';
import { getCliVersion } from '../version';

interface UpdateState {
lastCheck: number;
latest: string | null;
}

const CACHE_TTL_MS = 24 * 60 * 60 * 1000;

async function fetchLatest(): Promise<string | null> {
try {
const res = await fetch('https://registry.npmjs.org/@coreplane/polylane/latest', {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = (await res.json()) as { version?: string };
return data.version ?? null;
} catch {
return null;
}
}

function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x !== y) return x - y;
}
return 0;
}
import { updateCli } from '../update';

export const updateCommand: Command = {
name: 'update',
description: 'Check for CLI updates',
async execute(config: Config): Promise<void> {
const current = getCliVersion();
process.stderr.write(`Current version: ${current}\n`);

if (isCI() && !config.verbose) {
process.stderr.write('Skipping update check in CI\n');
return;
}

const latest = await fetchLatest();
if (!latest) {
process.stderr.write("Couldn't reach npm to check for updates. Try again later.\n");
return;
}

writeJsonFile(UPDATE_STATE_FILE, { lastCheck: Date.now(), latest });

if (compareVersions(latest, current) > 0) {
process.stderr.write(`Update available: ${latest}\n`);
process.stderr.write(`Run: npm install -g @coreplane/polylane@${latest}\n`);
process.stderr.write(` or: brew upgrade polylane\n`);
} else {
process.stderr.write(`You're up to date.\n`);
}
description: 'Update the active CLI install',
async execute(config): Promise<void> {
await updateCli(config);
},
};

export async function checkForUpdateAsync(): Promise<string | null> {
if (isCI()) return null;
const state = readJsonFile<UpdateState>(UPDATE_STATE_FILE);
if (state && Date.now() - state.lastCheck < CACHE_TTL_MS) {
const current = getCliVersion();
if (state.latest && compareVersions(state.latest, current) > 0) {
return state.latest;
}
return null;
}
const latest = await fetchLatest();
writeJsonFile(UPDATE_STATE_FILE, { lastCheck: Date.now(), latest });
const current = getCliVersion();
if (latest && compareVersions(latest, current) > 0) return latest;
return null;
}
76 changes: 76 additions & 0 deletions src/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { execFile } from 'node:child_process';
import { existsSync, realpathSync } from 'node:fs';
import { delimiter, dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { readJsonFile } from './utils/fs';

export interface Installation {
kind: 'standalone' | 'npm' | 'bun' | 'brew' | 'dev' | 'unknown';
file: string;
prefix?: string;
pinned?: boolean;
}

const exec = promisify(execFile);

export async function runProgram(program: string, args: string[], timeout = 10_000): Promise<string> {
let bin = program;
let argv = args;
if (process.platform === 'win32' && program === 'npm') {
const directories = [dirname(process.execPath), ...(process.env.PATH ?? '').split(delimiter)];
const script = directories.map((dir) => join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'))
.find((path) => existsSync(path));
if (!script) throw new Error('Cannot find npm-cli.js for the active Node installation');
bin = process.execPath;
argv = [script, ...args];
}
const { stdout } = await exec(bin, argv, {
timeout, maxBuffer: 2 * 1024 * 1024, windowsHide: true,
env: { ...process.env, POLYLANE_NO_AUTO_UPDATE: '1', npm_config_yes: 'true', NONINTERACTIVE: '1' },
});
return stdout.trim();
}

export async function detectInstallation(launcher = process.argv[1] ?? ''): Promise<Installation> {
let file: string;
try { file = realpathSync(launcher); }
catch { return { kind: 'unknown', file: resolve(launcher) }; }
const normalized = file.replaceAll('\\', '/');
if (normalized.endsWith('/src/main.ts')) return { kind: 'dev', file };

// The CLI file, not the Node executable, must belong to this Cellar.
if (/\/Cellar\/polylane\//i.test(normalized)) {
const cellar = await runProgram('brew', ['--cellar', 'polylane']);
if (file.startsWith(realpathSync(cellar) + '/')) {
const pinned = await runProgram('brew', ['list', '--pinned', 'polylane']);
return { kind: 'brew', file, pinned: pinned.split(/\s+/).includes('polylane') };
}
return { kind: 'unknown', file };
}

const packageRoot = dirname(dirname(file));
const pkg = readJsonFile<{ name?: string }>(join(packageRoot, 'package.json'));
if (pkg?.name === '@coreplane/polylane') {
if (existsSync(join(packageRoot, 'src', 'main.ts'))) return { kind: 'dev', file };
try {
const binDir = await runProgram('bun', ['pm', 'bin', '-g']);
if (realpathSync(join(binDir, 'polylane')) === file) return { kind: 'bun', file };
} catch { /* Bun is optional. */ }
try {
const root = await runProgram('npm', ['root', '--global']);
if (realpathSync(join(root, '@coreplane', 'polylane')) === packageRoot) {
const prefix = await runProgram('npm', ['prefix', '--global']);
return { kind: 'npm', file, prefix };
}
} catch { /* Local and npx installs are not global installs. */ }
return { kind: 'unknown', file };
}

if (normalized.endsWith('/polylane.mjs') && !normalized.includes('/node_modules/')) {
try {
if (realpathSync(join(dirname(file), 'polylane')) === file) return { kind: 'standalone', file };
} catch { /* Windows uses a cmd shim. */ }
if (process.platform === 'win32' && existsSync(join(dirname(file), 'polylane.cmd'))) return { kind: 'standalone', file };
}
return { kind: 'unknown', file };
}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import type { Credential } from './auth/types';
import { buildEvent, dispatch, maybeShowTelemetryNotice } from './telemetry';
import { getCliVersion } from './version';
import { updateCli } from './update';

const NO_AUTH_COMMANDS = new Set([
'auth login',
Expand Down Expand Up @@ -165,6 +166,7 @@ async function run(): Promise<void> {
// that timed out) flag it on process.exitCode; a hard exit(0) here used to
// discard it.
const exitCode = settledExitCode();
if (exitCode === 0 && command.name !== 'update') await updateCli(config, true);
if (!isTelemetryCommand) {
await dispatch(
config,
Expand Down
2 changes: 1 addition & 1 deletion src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const RESOURCE_ORDER: Record<string, ResourceGroup> = {
telemetry: { name: 'telemetry', description: 'Anonymous usage telemetry (status/enable/disable)', order: 95 },
api: { name: 'api', description: 'Raw API access (advanced)', order: 100 },
help: { name: 'help', description: 'Show help for a command', order: 110 },
update: { name: 'update', description: 'Check for CLI updates', order: 120 },
update: { name: 'update', description: 'Update the active CLI install', order: 120 },
};

export class CommandRegistry {
Expand Down
8 changes: 5 additions & 3 deletions src/telemetry/environment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { sep } from 'node:path';
import { release as osRelease, cpus as osCpus, totalmem } from 'node:os';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { INSTALL_REF_FILE } from '../config/paths';

export type InstallSource = 'npm' | 'bun' | 'brew' | 'curl' | 'dev' | 'unknown';
Expand All @@ -9,7 +9,9 @@ export type InstallSource = 'npm' | 'bun' | 'brew' | 'curl' | 'dev' | 'unknown';
// the launcher came from. False positives go to "unknown" rather than a wrong
// label. Used purely for distribution-channel analytics.
export function detectInstallSource(): InstallSource {
const candidates: string[] = [process.argv[1] ?? '', process.execPath ?? ''];
let launcher = process.argv[1] ?? '';
try { launcher = realpathSync(launcher); } catch { /* Keep an unresolved launcher as a best-effort label. */ }
const candidates: string[] = [launcher];
const haystack = candidates.join('|').toLowerCase();
if (!haystack) return 'unknown';

Expand All @@ -23,7 +25,7 @@ export function detectInstallSource(): InstallSource {
if (haystack.includes(`${sep}.bun${sep}`) || haystack.includes('/.bun/')) return 'bun';

// Homebrew Cellar / opt
if (haystack.includes(`${sep}cellar${sep}`) || haystack.includes(`${sep}homebrew${sep}`)) return 'brew';
if (haystack.includes(`${sep}cellar${sep}polylane${sep}`)) return 'brew';

// Generic npm-global location
if (haystack.includes(`${sep}npm${sep}`) || haystack.includes(`${sep}node_modules${sep}`)) return 'npm';
Expand Down
Loading
Loading