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 as `two_factor_session` inside that environment's entry in `.pos` — tightening the file to 0600, since it now holds a credential shorter-lived than the year-long token beside it — so the prompt appears once per session and not once per command. Settings taken from `MPKIT_*` have no `.pos` entry behind them and keep the session for the life of the process instead. `--otp-code` and `POS_PORTAL_OTP_CODE` skip the prompt for scripts, and `POS_PORTAL_SESSION_TOKEN` supplies a session minted elsewhere. 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. The prompt attributes the requirement to the Partner Portal account and lists the Instance, the portal and the account's email beneath it: 2FA is enabled on the account, not on the Instance, and `pos-cli deploy staging` names none of the three — so there was nothing on screen to confirm what a code was about to unlock. The email is the one stored for the environment in `.pos`; environments added through the browser device flow store none and that line is omitted. 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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ 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
│ ├── twoFactorSession.js # Instance 2FA sessions, cached in .pos per environment
│ ├── portal.js # Partner Portal API client
│ ├── watch.js # File watching for sync mode
│ ├── archive.js # Deployment archive creation
Expand Down Expand Up @@ -248,7 +250,7 @@ Centralized error handling with specific handlers for different HTTP status code
### Important Technical Details

#### Configuration Files
- `.pos` - Environment credentials (URL, token, email) as JSON
- `.pos` - Environment credentials (URL, token, email) as JSON. Also caches a `two_factor_session` (`{token, expires_at}`) per environment when an instance requires one; writing that tightens the file to 0600
- `.posignore` - Files to exclude from sync/deploy (gitignore syntax)
- `pos-module.json` - Universal platformOS project manifest (analogous to `package.json`). Its presence in a consuming app is normal — it lists `dependencies`. Publishable modules additionally have `machine_name`, `version`, and `name`. It is the **sole source** for all `modules` CLI commands (`install`, `update`, `push`, `version`, `migrate`).
- `pos-module.lock.json` - Resolved dependency versions (separate prod/dev sections) plus a `registries` map recording which registry each module was resolved from; makes the lock self-contained for `--frozen` mode
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,46 @@ 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
Your Partner Portal account (you@example.com) has 2FA enabled.
Instance: https://example.com
Portal: https://partners.platformos.com
Two-factor code (or a recovery code): 123456

2FA is enabled on your Partner Portal account, not on the Instance — the Instance only insists that the credential it is handed belongs to someone who has proved it. The Instance, portal and account are printed so you can confirm what a code is about to unlock, and which account it should come from, before typing it. The account is the email stored for the environment in `.pos`; environments added through the browser device flow store none, and that line is left out.

The Partner Portal decides how long the session lasts and pos-cli prints the expiry as it starts one (`Two-factor session started — it expires in 59 minutes.`). It is cached as `two_factor_session` inside that environment's entry in `.pos`, so every later command in that window runs without a prompt — one code unlocks the whole session, whichever command asked for it. Caching a session tightens `.pos` to owner-only (0600), since it now holds a credential shorter-lived than the year-long token. When settings come from `MPKIT_URL`/`MPKIT_EMAIL`/`MPKIT_TOKEN` there is no `.pos` entry to write to, and the session is kept only for the life of the process — enough for one long `sync`, but the next command asks again.

For scripted runs, set `POS_PORTAL_OTP_CODE`: it works for **every** command, while the `--otp-code` flag exists only on `deploy`, `sync`, `gui serve`, `env add`, `env refresh-token` and `modules push`. A recovery code works in either and does not expire on a timer. If your orchestrator already holds a session token, `POS_PORTAL_SESSION_TOKEN` supplies it directly and skips the exchange entirely; pos-cli only reads that variable, and never writes a session token to its output. `deploy`, `sync` and `gui serve` ask up front, before doing any work; other commands ask at the moment the instance refuses, and then retry the request that was refused. `gui serve` asks even without `--sync`, because the GUI proxies every panel query through the same credential. If a session expires part-way through a `sync`, watch mode stops rather than prompting into a queue that is mid-flight — it reports what did not reach the instance and asks you to restart the command you started, which takes a code once, up front. Under `gui serve --sync` that stops the web server too: the same session the watcher was refused is one no panel query could have used either.

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
14 changes: 13 additions & 1 deletion bin/pos-cli-deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { ensureSessionForCommand } from '../lib/twoFactorSession.js';

program
.name('pos-cli deploy')
Expand All @@ -14,6 +15,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 @@ -30,7 +35,10 @@ program
MARKETPLACE_EMAIL: authData.email,
MARKETPLACE_TOKEN: authData.token,
MARKETPLACE_URL: authData.url,
PARTNER_PORTAL_HOST: authData.partner_portal_url,
// Only when there is one: process.env stringifies, so assigning undefined here sets
// the literal "undefined", which Portal.url() and the Gateway would both read as a
// real portal URL.
...(authData.partner_portal_url ? { PARTNER_PORTAL_HOST: authData.partner_portal_url } : {}),
MARKETPLACE_ENV: environment,
CI: process.env.CI === 'true',
// TODO: Get rid off global system env, make it normal argument to function.
Expand All @@ -39,6 +47,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 ensureSessionForCommand(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
29 changes: 27 additions & 2 deletions bin/pos-cli-gui-serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fetchSettings } from '../lib/settings.js';
import { start as server } from '../lib/server.js';
import logger from '../lib/logger.js';
import ServerError from '../lib/ServerError.js';
import { ensureSessionForCommand } from '../lib/twoFactorSession.js';

const DEFAULT_CONCURRENCY = 3;

Expand All @@ -18,18 +19,37 @@ program
.option('-b, --host <host>', 'use HOST', 'localhost')
.option('-o, --open', 'when ready, open default browser with graphiql')
.option('-s, --sync', 'Sync files')
.option(
'--otp-code <otpCode>',
'two-factor code (or a recovery code) for the session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE'
)
.action(async (environment, params) => {
const authData = await fetchSettings(environment, program);
const partnerPortalHost = process.env.PARTNER_PORTAL_HOST || authData.partner_portal_url;

const env = Object.assign(process.env, {
MARKETPLACE_EMAIL: authData.email,
MARKETPLACE_TOKEN: authData.token,
MARKETPLACE_URL: authData.url,
HOST: params.host,
PORT: params.port,
CONCURRENCY: process.env.CONCURRENCY || DEFAULT_CONCURRENCY
CONCURRENCY: process.env.CONCURRENCY || DEFAULT_CONCURRENCY,
// watch.js and server.js both rebuild their Gateway settings from these variables,
// losing the environment's partner_portal_url on the way. A Gateway recovers it from
// .pos by URL, but Portal.url() — which every other portal call reads — cannot, so a
// private-stack instance would step up against the public portal without this. Same
// export `sync` and `deploy` make, and omitted rather than set to undefined for the
// same reason: process.env would store the string "undefined".
...(partnerPortalHost ? { PARTNER_PORTAL_HOST: partnerPortalHost } : {})
});

// Asked for before anything else, and not only when --sync is on: the GUI proxies
// every panel query through the same credential, and SwaggerProxy.client below already
// calls the instance. Without this the first step-up would be triggered by a browser
// request or a file save and raise a readline prompt from inside a running web server,
// where nobody is watching stdin. Here it is an ordinary prompt on an idle terminal.
await ensureSessionForCommand(authData, params);

try {
const client = await SwaggerProxy.client(environment);
server(env, client);
Expand All @@ -47,7 +67,12 @@ program
}

if (params.sync){
const { watcher, liveReloadServer } = await watch(env, true, false);
const { watcher, liveReloadServer } = await watch(env, true, false, {
// Names this command, not `pos-cli sync`, so an expired session tells the
// operator to restart the thing they actually started — the GUI server comes
// down with the watcher, so `pos-cli sync` alone would not bring it back.
restartCommand: ['pos-cli gui serve', environment, '--sync'].filter(Boolean).join(' ')
});
setupGracefulShutdown({ watcher, liveReloadServer, context: 'GUI' });
}
} catch (e) {
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
Loading