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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ polylane ... --non-interactive --quiet --output json
| `POLYLANE_OUTPUT` | `text` or `json` (overrides TTY auto-detect) |
| `POLYLANE_TIMEOUT` | Request timeout (seconds) |
| `POLYLANE_VERBOSE` | Enable verbose HTTP logging |
| `POLYLANE_HINTS` | `0` / `false` / `off` suppresses next-step hints (guidance only — never data, status, errors, or prompts) |
| `POLYLANE_TELEMETRY` | `0` / `false` / `off` disables anonymous usage telemetry |
| `POLYLANE_TELEMETRY_ENDPOINT` | Override the telemetry endpoint (defaults to `<api>/v1/telemetry/cli`) |
| `DO_NOT_TRACK` | Universal `1` disables telemetry ([standard](https://consoledonottrack.com/)) |
Expand All @@ -185,7 +186,8 @@ polylane ... --non-interactive --quiet --output json
"workspace_id": "ws_xxxxx...",
"api_key": "sk_xxxxx...",
"output": "text",
"timeout": 300
"timeout": 300,
"hints": true
}
```

Expand Down
1 change: 1 addition & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ polylane issue list --quiet 2>/dev/null
| `POLYLANE_OUTPUT` | `text` or `json` (overrides TTY auto-detect) |
| `POLYLANE_TIMEOUT` | Request timeout (seconds) |
| `POLYLANE_VERBOSE` | Verbose HTTP logs |
| `POLYLANE_HINTS` | `0` / `false` / `off` suppresses next-step hints (guidance only) |
| `POLYLANE_TELEMETRY` | `0` / `false` / `off` disables anonymous telemetry |
| `DO_NOT_TRACK` | `1` — universal opt-out |
| `NO_COLOR` | Disable ANSI colours |
Expand Down
15 changes: 3 additions & 12 deletions src/commands/auth/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,8 @@ function workspaceStep(landing?: Landing): string[] {
}
}

export function nextSteps(expiresAt: string, landing?: Landing): string {
export function nextSteps(landing?: Landing): string {
return [
`Signed in. Session valid until ${expiresAt}.`,
``,
`Onboarding (in order):`,
``,
...workspaceStep(landing),
Expand Down Expand Up @@ -205,13 +203,6 @@ function emitResult(config: Config, data: unknown): void {
if (config.output === 'json') formatOutput(config, data);
}

// The installer owns the post-sign-in journey (connects, mapping, topology
// link), so the CLI's own next-steps box would contradict it mid-flow. The
// env var is set per-invocation by the installer, never persisted.
function underInstaller(): boolean {
return Boolean(process.env.POLYLANE_ONBOARDING_RUN);
}

async function finishEmailSignIn(config: Config, email: string, session: VerifiedSession): Promise<void> {
if (!session.token) {
emitResult(config, { landing: session.landing });
Expand All @@ -221,7 +212,7 @@ async function finishEmailSignIn(config: Config, email: string, session: Verifie
writeSessionCredential(session.token, session.expiresAt, email);
await persistDefaultWorkspace(config);
emitResult(config, { token: session.token, landing: session.landing });
if (!underInstaller()) note(nextSteps(session.expiresAt, session.landing), 'Next steps');
if (config.hints) note(nextSteps(session.landing), 'Next steps');
outro(`Signed in as ${email}.`);
}

Expand Down Expand Up @@ -307,7 +298,7 @@ export async function emailSignup(config: Config, args: Record<string, unknown>)
writeSessionCredential(token, expiresAt, user.email ?? user.id);
await persistDefaultWorkspace(config);
emitResult(config, json.result);
if (!underInstaller()) note(nextSteps(expiresAt), 'Next steps');
if (config.hints) note(nextSteps(), 'Next steps');
outro(`Signed in as ${user.email ?? user.id}.`);
return;
}
Expand Down
30 changes: 18 additions & 12 deletions src/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@ import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { requireArg } from '../helpers';

const VALID_KEYS = new Set(['domain', 'workspace_id', 'api_key', 'agent', 'output', 'timeout', 'telemetry']);
const VALID_KEYS = new Set(['domain', 'workspace_id', 'api_key', 'agent', 'output', 'timeout', 'telemetry', 'hints']);

function parseBooleanValue(key: string, value: string): boolean {
const truthy = ['1', 'true', 'yes', 'on', 'enabled'];
const falsy = ['0', 'false', 'no', 'off', 'disabled'];
if (truthy.includes(value)) return true;
if (falsy.includes(value)) return false;
throw new CLIError(
`Invalid ${key} value: "${value}"`,
ExitCode.USAGE,
'Use true/false, yes/no, on/off, 1/0, or enabled/disabled'
);
}

export const configSetCommand: Command = {
name: 'config set',
Expand Down Expand Up @@ -69,17 +81,11 @@ export const configSetCommand: Command = {
break;
}
case 'telemetry': {
const truthy = ['1', 'true', 'yes', 'on', 'enabled'];
const falsy = ['0', 'false', 'no', 'off', 'disabled'];
if (truthy.includes(value)) partial.telemetry = true;
else if (falsy.includes(value)) partial.telemetry = false;
else {
throw new CLIError(
`Invalid telemetry value: "${value}"`,
ExitCode.USAGE,
'Use true/false, yes/no, on/off, 1/0, or enabled/disabled'
);
}
partial.telemetry = parseBooleanValue(key, value);
break;
}
case 'hints': {
partial.hints = parseBooleanValue(key, value);
break;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/config/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const configShowCommand: Command = {
agent: config.agent ?? null,
output: config.output,
timeout: config.timeout,
hints: config.hints,
apiKey: config.apiKey ? maskToken(config.apiKey) : null,
authMethod,
configFile: CONFIG_FILE,
Expand Down
17 changes: 16 additions & 1 deletion src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ function parseEnvNumber(v: string | undefined): number | undefined {

function parseEnvBoolean(v: string | undefined): boolean | undefined {
if (v === undefined) return undefined;
if (v === '' || v === '0' || v === 'false' || v === 'no') return false;
// 'off' and case-insensitivity match what the README has always documented
// for POLYLANE_TELEMETRY (and now POLYLANE_HINTS).
const norm = v.toLowerCase();
if (norm === '' || norm === '0' || norm === 'false' || norm === 'no' || norm === 'off') return false;
return true;
}

Expand Down Expand Up @@ -101,6 +104,17 @@ export function loadConfig(flags: GlobalFlags): Config {
return true;
})();

// Hints are next-step guidance for humans. An orchestrator that owns the
// journey (e.g. the install script) sets POLYLANE_HINTS=0 so commands stay
// composable inside its flow. Same boolean model as telemetry: env → config
// file → default on. No CLI flag until a per-invocation need shows up.
const hints = ((): boolean => {
const fromEnv = parseEnvBoolean(env.POLYLANE_HINTS);
if (fromEnv !== undefined) return fromEnv;
if (file.hints !== undefined) return file.hints;
return true;
})();

return {
apiKey,
domain,
Expand All @@ -114,5 +128,6 @@ export function loadConfig(flags: GlobalFlags): Config {
dryRun,
nonInteractive,
telemetry,
hints,
};
}
5 changes: 5 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface Config {
dryRun: boolean;
nonInteractive: boolean;
telemetry: boolean;
/** Next-step guidance for humans (git's advice.* category). Suppressible
* by orchestrators that own the journey; never gates data, status lines,
* errors, prompts, or consent notices. */
hints: boolean;
}

export interface RawConfig {
Expand All @@ -26,6 +30,7 @@ export interface RawConfig {
output?: OutputFormat;
timeout?: number;
telemetry?: boolean;
hints?: boolean;
}

// process.env.POLYLANE_API_DOMAIN is replaced at build time via esbuild define
Expand Down
1 change: 1 addition & 0 deletions test/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function mockConfig(overrides: Partial<Config> = {}): Config {
noColor: true,
dryRun: false,
nonInteractive: true,
hints: true,
...overrides,
};
}
18 changes: 18 additions & 0 deletions test/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('loadConfig', () => {
delete process.env.POLYLANE_OUTPUT;
delete process.env.POLYLANE_VERBOSE;
delete process.env.POLYLANE_AGENT;
delete process.env.POLYLANE_HINTS;
});

afterEach(() => {
Expand Down Expand Up @@ -59,4 +60,21 @@ describe('loadConfig', () => {
const config = loadConfig({} as GlobalFlags);
assert.equal(config.agent, undefined);
});

it('hints default on', () => {
const config = loadConfig({} as GlobalFlags);
assert.equal(config.hints, true);
});

it('POLYLANE_HINTS=0 disables hints', () => {
process.env.POLYLANE_HINTS = '0';
const config = loadConfig({} as GlobalFlags);
assert.equal(config.hints, false);
});

it('POLYLANE_HINTS=1 enables hints', () => {
process.env.POLYLANE_HINTS = '1';
const config = loadConfig({} as GlobalFlags);
assert.equal(config.hints, true);
});
});
25 changes: 9 additions & 16 deletions test/signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,11 @@ describe('auth signup existing-account re-auth', () => {
assert.equal(config.workspace_id, WORKSPACE_ID);
});

it('prints next steps standalone but suppresses them under the installer', async () => {
it('prints next steps by default but not with hints disabled', async () => {
await run({ output: 'text' });
assert.ok(output.includes('Onboarding (in order)'));

process.env.POLYLANE_ONBOARDING_RUN = 'run_test';
try {
await run({ output: 'text' });
} finally {
delete process.env.POLYLANE_ONBOARDING_RUN;
}
await run({ output: 'text', hints: false });
assert.ok(!output.includes('Onboarding (in order)'));
assert.ok(output.includes('Signed in as dev@acme.com.'));
});
Expand Down Expand Up @@ -386,44 +381,42 @@ describe('auth signup --code (email verification)', () => {
});

describe('nextSteps', () => {
const expiresAt = '2026-08-10T00:00:00.000Z';

it('names a created workspace and does not suggest creating one', () => {
const text = nextSteps(expiresAt, { kind: 'created', workspaceSlug: 'acme' });
const text = nextSteps({ kind: 'created', workspaceSlug: 'acme' });
assert.ok(text.includes('Your first workspace ("acme") was created'));
assert.ok(!text.includes('polylane workspace create'));
});

it('names a joined workspace and does not suggest creating one', () => {
const text = nextSteps(expiresAt, { kind: 'joined', workspaceSlug: 'inviter' });
const text = nextSteps({ kind: 'joined', workspaceSlug: 'inviter' });
assert.ok(text.includes('You joined the "inviter" workspace'));
assert.ok(!text.includes('polylane workspace create'));
});

it('points existing members at picking a default workspace', () => {
const text = nextSteps(expiresAt, { kind: 'existing' });
const text = nextSteps({ kind: 'existing' });
assert.ok(text.includes('Set your default workspace'));
assert.ok(!text.includes('polylane workspace create'));
});

it('suggests creating a workspace when the landing kind is none', () => {
const text = nextSteps(expiresAt, { kind: 'none' });
const text = nextSteps({ kind: 'none' });
assert.ok(text.includes('polylane workspace create'));
});

it('suggests creating a workspace when no landing is present', () => {
const text = nextSteps(expiresAt);
const text = nextSteps();
assert.ok(text.includes('polylane workspace create'));
});

it('does not crash when a created landing has no workspaceSlug', () => {
const text = nextSteps(expiresAt, { kind: 'created' });
const text = nextSteps({ kind: 'created' });
assert.ok(text.includes('Your first workspace was created'));
assert.ok(!text.includes('polylane workspace create'));
});

it('suggests creating a workspace for an invite at capacity', () => {
const text = nextSteps(expiresAt, {
const text = nextSteps({
kind: 'invite_at_capacity',
workspace: { id: 'ws_1', name: 'Inviter' },
});
Expand Down
Loading