Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### New Features

* Every command that talks to an instance now obtains a two-factor session when that instance's Partner Portal requires one — `deploy`, `sync`, `exec`, `exec-graphql`, `exec-liquid`, `constants`, `data export`/`import`, `migrations`, `logs`, `pull` and the GUI. Reads are covered as well as writes: a token reaches every record through GraphQL and runs arbitrary Liquid through `exec liquid`, so protecting only deploys would have protected only the source code. `deploy` and `sync` ask up front, before doing any work; the rest ask when the instance refuses and then retry. The code is exchanged with the **Portal** for a short-lived session token (8 hours) which is cached per portal+instance under `~/.pos-cli/sessions.json` (0600), so the prompt appears once per session and not once per command; `--otp-code` and `POS_PORTAL_OTP_CODE` skip it for scripts. The prompt is raised before any spinner starts — a spinner repaints its line on a timer and used to paint straight over it, which looked like a hang. Note that the code never travels through the instance: instances run tenant-authored Liquid, so one that passed through could be harvested and replayed inside its 30-second window. Requires the matching Partner Portal and platformOS releases; against a portal or instance without them, nothing changes.

* Partner Portal accounts with two-factor authentication enabled can now authenticate from the CLI. `pos-cli env add --email`, `pos-cli env refresh-token`, `pos-cli modules push` and the `pos-cli dns` email fallback prompt for a code (a recovery code works too) when the portal asks for a second factor, and retry the request with it. `--otp-code <code>` and the `POS_PORTAL_OTP_CODE` environment variable skip the prompt for scripted use; a non-interactive run explains what to set instead of hanging on a prompt that nobody can answer. A rejected code says so instead of blaming the password, and an account the portal has locked for too many attempts stops immediately rather than spending prompts on codes that would be refused unread. pos-cli gives up after three rejected codes, short of the portal's 5-attempt budget, so a typo here cannot trigger the 15-minute lock that is shared with the web UI. Previously these commands reported every one of these as "check if your email/password are correct", which left no way to tell a 2FA challenge from a wrong password — the browser-based `pos-cli env add --url` device flow was unaffected and remains the simplest option. Portals older than the `two_factor_invalid`/`two_factor_locked` responses are still handled: a code pos-cli sent itself can only have been refused for being wrong, since the portal would not have asked for one unless the password had already passed.

## 6.4.0 (2026-08-20)

### New Features
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pos-cli/
│ ├── ServerError.js # Centralized error handling
│ ├── settings.js # Environment configuration (.pos file)
│ ├── environments.js # Authentication flows
│ ├── utils/twoFactor.js # Partner Portal 2FA: prompt/retry around password auth
│ ├── portal.js # Partner Portal API client
│ ├── watch.js # File watching for sync mode
│ ├── archive.js # Deployment archive creation
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,42 @@ Note that [`modules install`/`update`](#installation) take their registry URL fr

The Instance details page in the Partner Portal shows the `env add` command pre-filled with both URLs, ready to copy.

#### Two-Factor Authentication

If your Partner Portal account has two-factor authentication enabled, the token `env add` mints is good for a year against every Instance you can deploy to, so the portal asks for a second factor before issuing one.

Nothing extra is needed for the default flow: `pos-cli env add [environment] --url [url]` (no `--email`) authorizes in the browser, where you answer the 2FA challenge like any other portal login.

When you authenticate with `--email`, pos-cli prompts for the code after your password:

pos-cli env add staging --url https://example.com --email you@example.com
Password: ******
This account has two-factor authentication enabled. Your password was accepted.
Two-factor code (or a recovery code): 123456

A recovery code from the list you saved when you enabled 2FA is accepted anywhere the six-digit code is. To skip the prompt, pass `--otp-code` or set `POS_PORTAL_OTP_CODE`:

pos-cli env add staging --url https://example.com --email you@example.com --otp-code 123456
POS_PORTAL_OTP_CODE=123456 pos-cli env refresh-token staging

The same applies to `pos-cli env refresh-token` and `pos-cli modules push`. In a non-interactive environment (CI, a `--json` run) pos-cli will not prompt — supply `POS_PORTAL_OTP_CODE`, or prefer `pos-cli env add [environment] --url [url] --token [token]`, which needs neither a password nor a code.

#### Instance Sessions

An instance can require that it is used with a credential whose holder has proved a second factor — the year-long token in `.pos` is not one. This covers **every command that talks to the instance**, not just deploys: `deploy`, `sync`, `exec`, `exec-graphql`, `exec-liquid`, `constants`, `data export`/`import`, `migrations`, `logs`, `pull`, the GUI. A token reaches every record in the instance through GraphQL and runs arbitrary Liquid through `exec liquid`, so reads are not exempt.

The first command that needs one asks for a code:

pos-cli deploy staging
This instance requires a two-factor code.
Two-factor code (or a recovery code): 123456

The resulting session lasts 8 hours and is cached in `~/.pos-cli/sessions.json` (owner-readable only), so every later command in that window runs without a prompt — one code unlocks the whole session, whichever command asked for it.

For scripted runs, set `POS_PORTAL_OTP_CODE`: it works for **every** command, while the `--otp-code` flag exists only on `deploy`, `sync`, `env add`, `env refresh-token` and `modules push`. A recovery code works in either and does not expire on a timer. `deploy` and `sync` ask up front, before doing any work; other commands ask at the moment the instance refuses, and then retry the request that was refused.

pos-cli stops after three rejected codes. The Partner Portal locks an account for 15 minutes after five, and that counter is shared with the web UI, so the remaining attempts are left for you to spend deliberately. If the account is already locked, pos-cli says so and stops without asking for a code — while the lock holds, even a correct code is refused unread.

The configuration for your environments is stored in the `.pos` file.

### Syncing Changes
Expand Down
27 changes: 27 additions & 0 deletions bin/pos-cli-deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ import { program } from '../lib/program.js';
import { fetchSettings } from '../lib/settings.js';
import logger from '../lib/logger.js';
import deployStrategy from '../lib/deploy/strategy.js';
import { ensureSession } from '../lib/twoFactorSession.js';
import { reportCommandError } from '../lib/reportCommandError.js';

// Letting a TwoFactorError escape the action would print a stack trace over the message it
// carries. Anything else keeps its existing behaviour.
const ensureTwoFactorSession = async (authData, params) => {
try {
await ensureSession({
portalUrl: authData.partner_portal_url,
instanceUrl: authData.url,
token: authData.token,
otpCode: params.otpCode
});
} catch (e) {
if (e.name !== 'TwoFactorError') throw e;

await reportCommandError(e);
}
};

program
.name('pos-cli deploy')
Expand All @@ -14,6 +33,10 @@ program
.option('-p --partial-deploy', 'Partial deployment, does not remove data from directories missing from the build')
.option('--dry-run', 'Validate the release on the server without applying any changes')
.option('-v, --verbose', 'Show full file paths in deploy report (default: summary only)')
.option(
'--otp-code <otpCode>',
'two-factor code (or a recovery code) for the deploy session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE'
)
.action(async (environment, params) => {
if (params.force) logger.Warn('-f flag is deprecated and does not do anything.');

Expand All @@ -39,6 +62,10 @@ program
VERBOSE: !!params.verbose
});

// Before any work or any spinner: if this instance needs a two-factor session, ask for
// the code now rather than partway through the upload.
await ensureTwoFactorSession(authData, params);

deployStrategy.run({ strategy, opts: { env, authData, params } });
});

Expand Down
12 changes: 6 additions & 6 deletions bin/pos-cli-env-add.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
#!/usr/bin/env node

import { program } from '../lib/program.js';
import ServerError from '../lib/ServerError.js';
import logger from '../lib/logger.js';
import { reportCommandError } from '../lib/reportCommandError.js';
import addEnv from '../lib/envs/add.js';

program.showHelpAfterError();
Expand All @@ -20,14 +19,15 @@ program
'--token <token>',
'if you have a token you can add it directly to pos-cli configuration without connecting to portal'
)
.option(
'--otp-code <otpCode>',
'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only needed with --email; you are prompted for one when it is missing'
)
.action(async (environment, params) => {
try {
await addEnv(environment, params);
} catch (e) {
if (ServerError.isNetworkError(e))
await ServerError.handler(e);
else
await logger.Error(e);
await reportCommandError(e);
}
});

Expand Down
16 changes: 8 additions & 8 deletions bin/pos-cli-env-refresh-token.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { program } from '../lib/program.js';
import logger from '../lib/logger.js';
import { fetchSettings } from '../lib/settings.js';
import refreshToken from '../lib/envs/refreshToken.js';
import ServerError from '../lib/ServerError.js';
import { reportCommandError } from '../lib/reportCommandError.js';

program
.name('pos-cli env refresh-token')
.arguments('[environment]', 'name of environment. Example: staging')
.action(async (environment, _params) => {
.option(
'--otp-code <otpCode>',
'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only used by environments that store an email; you are prompted for one when it is missing'
)
.action(async (environment, params) => {
try {
const authData = await fetchSettings(environment);
await refreshToken(environment, authData);
await refreshToken(environment, authData, { otpCode: params.otpCode });
} catch (e) {
if (ServerError.isNetworkError(e))
await ServerError.handler(e);
else
await logger.Error(e);
await reportCommandError(e);
process.exit(1);
}
});
Expand Down
3 changes: 2 additions & 1 deletion bin/pos-cli-modules-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { program } from '../lib/program.js';
import Gateway from '../lib/proxy.js';
import logger from '../lib/logger.js';
import { fetchSettings } from '../lib/settings.js';
import { reportCommandError } from '../lib/reportCommandError.js';

program
.name('pos-cli modules list')
Expand All @@ -22,7 +23,7 @@ program
logger.Info(`\t- ${module}`, { hideTimestamp: true });
});
}
}).catch(logger.Debug);
}).catch(error => reportCommandError(error, { prefix: 'Listing modules failed' }));
});

program.parse(process.argv);
4 changes: 4 additions & 0 deletions bin/pos-cli-modules-push.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ program
.requiredOption('--email <email>', 'Partner Portal account email. Example: foo@example.com')
.option('--path <path>', 'module root directory, default is current directory')
.option('--name <name>', 'name of the module you would like to publish')
.option(
'--otp-code <otpCode>',
'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only needed with --email; you are prompted for one when it is missing'
)
.action(async (params) => {
if (params.path) process.chdir(params.path);
checkParams(params);
Expand Down
33 changes: 32 additions & 1 deletion bin/pos-cli-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,28 @@ import { start as watchStart, setupGracefulShutdown, sendFile } from '../lib/wat
import { fetchSettings } from '../lib/settings.js';
import logger from '../lib/logger.js';
import Gateway from '../lib/proxy.js';
import { ensureSession } from '../lib/twoFactorSession.js';
import { reportCommandError } from '../lib/reportCommandError.js';

const DEFAULT_CONCURRENCY = 3;

// Letting a TwoFactorError escape the action would print a stack trace over the message it
// carries. Anything else keeps its existing behaviour.
const ensureTwoFactorSession = async (authData, params) => {
try {
await ensureSession({
portalUrl: authData.partner_portal_url,
instanceUrl: authData.url,
token: authData.token,
otpCode: params.otpCode
});
} catch (e) {
if (e.name !== 'TwoFactorError') throw e;

await reportCommandError(e);
}
};

program
.name('pos-cli sync')
.argument('[environment]', 'Name of environment. Example: staging')
Expand All @@ -17,15 +36,27 @@ program
.option('-o, --open', 'When ready, open default browser with instance')
.option('-f, --file-path <file-path>', 'sync single file and exit')
.option('-l, --livereload', 'Use livereload')
.option(
'--otp-code <otpCode>',
'two-factor code (or a recovery code) for the deploy session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE'
)
.action(async (environment, params) => {
const authData = await fetchSettings(environment);
const env = Object.assign(process.env, {
MARKETPLACE_EMAIL: authData.email,
MARKETPLACE_TOKEN: authData.token,
MARKETPLACE_URL: authData.url,
CONCURRENCY: process.env.CONCURRENCY || params.concurrency
CONCURRENCY: process.env.CONCURRENCY || params.concurrency,
// watch.js rebuilds the Gateway's settings from these env vars, losing the
// environment's partner_portal_url on the way; the Gateway reads it back from here
// so a two-factor session lands under the right portal. Same export `deploy` does.
PARTNER_PORTAL_HOST: process.env.PARTNER_PORTAL_HOST || authData.partner_portal_url
});

// Asked for before the watcher starts and before any spinner: sync then runs
// unattended for hours, and a prompt raised underneath a spinner is painted over.
await ensureTwoFactorSession(authData, params);

// Handle single file sync
if (params.filePath) {
const gateway = new Gateway({
Expand Down
9 changes: 5 additions & 4 deletions lib/dns/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ const resolveSettings = (envName, label) => {
return settings;
};

const authenticateInteractively = async (baseUrl, email) => {
const authenticateInteractively = async (baseUrl, email, otpCode) => {
await logger.Info(`Authenticating ${email} on ${baseUrl}`);
const password = await readPassword();
return DnsPortalClient.authenticate(baseUrl, email, password);
return DnsPortalClient.authenticate(baseUrl, email, password, { otpCode, interactive: true });
};

// Resolves everything a dns command needs to talk to one portal ("source" or "target"):
Expand All @@ -34,6 +34,7 @@ const resolvePortalContext = async (envName, {
portalUrl,
token,
email,
otpCode,
instanceUuid,
label = 'portal',
readOnly = false,
Expand Down Expand Up @@ -62,7 +63,7 @@ const resolvePortalContext = async (envName, {
'which would corrupt --json output — pass --token or use a stored environment token instead.'
);
}
authToken = await authenticateInteractively(baseUrl, email);
authToken = await authenticateInteractively(baseUrl, email, otpCode);
}
if (!authToken) {
throw new Error(
Expand All @@ -80,7 +81,7 @@ const resolvePortalContext = async (envName, {
if (!(error instanceof PortalAuthError) || email || !fallbackEmail || !tty || !interactive) throw error;

await logger.Warn(error.message);
authToken = await authenticateInteractively(baseUrl, fallbackEmail);
authToken = await authenticateInteractively(baseUrl, fallbackEmail, otpCode);
client = new DnsPortalClient({ baseUrl, token: authToken, readOnly });
await client.listInstances();
}
Expand Down
20 changes: 14 additions & 6 deletions lib/dns/portalClient.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { apiRequest } from '../apiRequest.js';
import { withTwoFactor } from '../utils/twoFactor.js';

const DESTRUCTIVE_PREFIX = 'Destructive DNS change blocked';

Expand Down Expand Up @@ -112,14 +113,21 @@ class DnsPortalClient {
this.readOnly = readOnly;
}

static async authenticate(baseUrl, email, password) {
// otpCode/interactive are passed through to withTwoFactor: a 2FA account answers a
// password-only request with 401 two_factor_required, which would otherwise be reported
// as an expired token. `interactive: false` (a --json run, where a prompt would corrupt
// the output) turns that into an explanatory error instead of a prompt.
static async authenticate(baseUrl, email, password, { otpCode, interactive } = {}) {
const base = normalizeBaseUrl(baseUrl);
try {
const response = await apiRequest({
method: 'POST',
uri: `${base}/api/authenticate`,
body: { email, password }
});
const response = await withTwoFactor(
code => apiRequest({
method: 'POST',
uri: `${base}/api/authenticate`,
body: code ? { email, password, otp_code: code } : { email, password }
}),
{ otpCode, interactive }
);
if (!response || !response.auth_token) throw new PortalAuthError(base);
return response.auth_token;
} catch (error) {
Expand Down
12 changes: 9 additions & 3 deletions lib/envs/add.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import logger from '../logger.js';
import * as validate from '../validators/index.js';
import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js';
import { readPassword } from '../utils/password.js';
import { withTwoFactor } from '../utils/twoFactor.js';

const checkParams = (env, params) => {
if (params.email) validate.email(params.email);
Expand All @@ -18,8 +19,8 @@ const saveToken = (settings, token) => {
logger.Success(`Environment ${settings.url} as ${settings.environment} has been added successfully.`);
};

const login = async (email, password, url) => {
return Portal.login(email, password, url)
const login = async (email, password, url, otpCode) => {
return Portal.login(email, password, url, otpCode)
.then(response => {
if (response) return Promise.resolve(response[0].token);
});
Expand Down Expand Up @@ -56,7 +57,12 @@ const addEnv = async (environment, params) => {
const password = await readPassword();
logger.Info(`Asking ${Portal.url()} for access token...`);

token = await login(params.email, password, params.url);
// The token this mints is good for a year against every instance the user can deploy
// to, so the portal asks a 2FA account for its second factor before issuing one.
token = await withTwoFactor(
otpCode => login(params.email, password, params.url, otpCode),
{ otpCode: params.otpCode }
);
}

if (token) {
Expand Down
12 changes: 8 additions & 4 deletions lib/envs/refreshToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import Portal from '../portal.js';
import logger from '../logger.js';
import { readPassword } from '../utils/password.js';
import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js';
import { withTwoFactor } from '../utils/twoFactor.js';

const login = async (email, password, url) => {
return Portal.login(email, password, url)
const login = async (email, password, url, otpCode) => {
return Portal.login(email, password, url, otpCode)
.then(response => {
if (response) return Promise.resolve(response[0].token);
});
};

const refreshToken = async (environment, authData) => {
const refreshToken = async (environment, authData, { otpCode } = {}) => {
let token;

if (!authData.email) {
Expand All @@ -24,7 +25,10 @@ const refreshToken = async (environment, authData) => {
const password = await readPassword();
logger.Info(`Asking ${Portal.url()} for access token...`);

token = await login(authData.email, password, authData.url);
token = await withTwoFactor(
code => login(authData.email, password, authData.url, code),
{ otpCode }
);
}

if (token) {
Expand Down
Loading
Loading