Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b853dc9
fix(rules): print the server message of a 409 or 422 deploy refusal
stooit Nov 4, 2026
400c56e
fix(serve): keep serving when the --run command moves its dev server …
stooit Nov 4, 2026
d0a6559
feat(kv): quant kv set --local --secret stores the value encrypted
stooit Nov 4, 2026
41b39e2
feat(kv): quant/serve.json gives org, project and meta defaults
stooit Nov 4, 2026
cf49cfe
feat(serve): warn when quant/serve.json and quant.json name a differe…
stooit Nov 4, 2026
529da64
feat(kv): the kv set menu asks whether to store the value as a secret
stooit Nov 4, 2026
6bef29d
fix(rules): keep the advice when a 409 or 422 body has only an error …
stooit Nov 4, 2026
79db7fa
fix(serve): stop the wait for the --proxy URL at once when quant serv…
stooit Nov 4, 2026
7623225
fix(kv): never print a secret value after kv set --secret
stooit Nov 4, 2026
dcd0a95
docs: secret output, quant/serve.json errors and store ids, --project…
stooit Nov 4, 2026
2d4fb87
release: 7.3.0
stooit Nov 4, 2026
14b3a4a
feat(serve): portal API client for read-only pulls
stooit Nov 4, 2026
00789ee
fix(serve): portal client review fixes
stooit Nov 4, 2026
a42d403
test(serve): pin token redaction before truncation
stooit Nov 4, 2026
88901b6
feat(serve): redirect, headers, custom response, basic auth, error pa…
stooit Nov 4, 2026
8ea2042
feat(serve): --pull-rules runs the portal rules in edge order
stooit Nov 4, 2026
bc78b7e
fix(serve): pull-rules review fixes
stooit Nov 4, 2026
c036f32
feat(serve): read one remote KV store through the portal API
stooit Nov 4, 2026
fcd5dd3
fix(serve): count portal retries in the remote KV read cap
stooit Nov 4, 2026
44fc315
feat(serve): --kv-remote serves one portal KV store from a start-up s…
stooit Nov 4, 2026
8cdec06
fix(serve): kv-remote review fixes
stooit Nov 4, 2026
04d099f
test(serve): phase 3 end-to-end against a local portal
stooit Nov 4, 2026
bff5ffa
refactor(serve): remove the empty later-release flag gate
stooit Nov 4, 2026
9e850b0
docs(serve): portal rules and remote KV
stooit Nov 4, 2026
74e9082
test(serve): stop the CLI on a stalled start-up; docs fixes
stooit Nov 4, 2026
e848cfe
fix(serve): keep pulled managed rules that the local file does not re…
stooit Nov 4, 2026
b92a2e7
fix(serve): share the portal request budget and soften header read er…
stooit Nov 4, 2026
1967fab
refactor(serve): one copy of the shared rule helpers
stooit Nov 4, 2026
da72360
docs(serve): drop an internal name from a code comment
stooit Nov 4, 2026
87e22a4
release: 7.4.0
stooit Nov 4, 2026
6f4f9bf
fix(serve): find the --kv-remote store by user_name or store id
stooit Nov 4, 2026
9f20f55
feat(serve): print the count of pulled global headers at start
stooit Nov 4, 2026
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
99 changes: 90 additions & 9 deletions README.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions docs/adopting-quant-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ bundles function sources into the committed function bodies.
Give the dev server `--host 127.0.0.1` when it listens only on IPv6 by
default, or the proxy cannot reach it.

In some environments the dev server moves to the background and the `--run`
command exits 0 at once. quant serve then prints that the command moved to the
background and keeps serving. On macOS and Linux, it stops the dev server when
it stops, but only while the dev server stays in the process group of the
`--run` command. On Windows, quant serve cannot stop a dev server that moved to
the background. See the `--run` flag in the README.

3. Move the seed data into fixtures. Each store gets one file,
`quant/kv-fixtures/<store-name>.json`, with a JSON array of entries:

Expand Down Expand Up @@ -117,6 +124,15 @@ bundles function sources into the committed function bodies.
- `quant serve --indexes building` and `--kv-lag 1000` show the not-ready and
list-lag states that production can have, so you can test the messages for them.

## Use the rules and data of the portal project

To run the site against the rules and a KV store of the real project, start
with `quant serve --pull-rules --kv-remote <store>`. Both flags read the portal
once at start, keep the data in memory and never write it to `.quant/`. They
need `QUANT_API_TOKEN` and `QUANT_BASE_URL`. See
[Portal rules and remote KV](../README.md#portal-rules-and-remote-kv) for the
scopes, the caps and the rule actions that do not run locally.

## Limits

quant serve runs documented edge behaviour. It does not run scheduled
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quantcdn/quant-cli",
"version": "7.2.0",
"version": "7.4.0",
"description": "Deploy tools for QuantCDN",
"type": "module",
"main": "cli.js",
Expand Down
70 changes: 51 additions & 19 deletions src/commands/kv.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import fs from 'node:fs';
import path from 'node:path';
import { select, text, isCancel } from '@clack/prompts';
import { select, text, password, confirm, isCancel } from '@clack/prompts';
import { openLocalKv } from '../serve/kv/wire.js';
import { pendingLedgerWarnings } from '../serve/kv/ledgers.js';
import { DEFAULT_FILES } from '../serve/project.js';
Expand All @@ -16,6 +16,8 @@ import { resolveOptions } from './serve.js';
const REMOTE_MESSAGE = 'Remote KV commands are not available yet. Use --local.';
const ACTIONS = ['list', 'get', 'set', 'delete'];
const MAX_PAGES = 100;
// The mask of an encrypted value, the same as in a list.
const ENCRYPTED = '[ENCRYPTED]';

/** A failure with a message for the user and no stack. */
class UsageError extends Error {}
Expand Down Expand Up @@ -91,14 +93,24 @@ function need(value, label) {
return value;
}

// The set options: ifVersion, and secret (the value is stored encrypted, as
// with `secret: true` in a function: a function reads it, a list shows it masked).
function setOptions(args) {
const options = {};
if (args['if-version'] !== undefined) options.ifVersion = args['if-version'];
if (args.secret === true) options.secret = true;
return Object.keys(options).length > 0 ? options : undefined;
}

const OPERATIONS = {
list: (store, args) => listAll(store, args),
get: (store, args) => store.getEntry(need(args.key, 'The key')),
async set(store, args) {
const value = parseJson(need(args.value, 'The JSON value'));
const ifVersion = args['if-version'];
await store.set(need(args.key, 'The key'), value, ifVersion === undefined ? undefined : { ifVersion });
return store.getEntry(args.key);
await store.set(need(args.key, 'The key'), value, setOptions(args));
const entry = await store.getEntry(args.key);
// Never print a secret value after a write: it would go to logs and scrollback.
return entry && entry.encrypted ? { ...entry, value: ENCRYPTED } : entry;
},
async delete(store, args) {
await store.delete(need(args.key, 'The key'));
Expand All @@ -113,14 +125,16 @@ function describeError(err) {

async function execute(args, root, error) {
if (!ACTIONS.includes(args.action)) throw new UsageError(`The action must be one of: ${ACTIONS.join(', ')}`);
if (args.secret === true && args.action !== 'set') error(`--secret applies only to set. It is ignored for ${args.action}.`);
const options = resolveOptions({ org: args.org, project: args.project, meta: args.meta }, { root, gitUserName: () => null });
const id = idOf(options, args.store);
if (id === null) throw new UsageError(`FORBIDDEN: Access to KV store ${args.store} is not allowed`);
// kv only creates missing stores: it declares no index and holds no index state.
const stores = readStores(root, options.meta).map((store) => ({ ...store, indexes: [] }));
const local = await openLocalKv({ root, org: options.org, project: options.project, stores });
// The warnings come before the operation, so a failed operation keeps them.
for (const warning of [...local.warnings, ...pendingLedgerWarnings(local.backend, local.backend.storeIds())]) error(warning);
const warnings = [...options.configWarnings, ...local.warnings, ...pendingLedgerWarnings(local.backend, local.backend.storeIds())];
for (const warning of warnings) error(warning);
return OPERATIONS[args.action](local.kvStore.open(id), args);
}

Expand All @@ -144,18 +158,35 @@ export async function runKv(args, { root = process.cwd(), print = console.log, e
}
}

// Ask for the action, the store and what the action needs. Null on cancel.
async function promptArgs() {
const action = await select({ message: 'Operation', options: ACTIONS.map((value) => ({ value, label: value })) });
if (isCancel(action)) return null;
const store = await text({ message: 'Store name (from quant/meta.json)' });
if (isCancel(store)) return null;
const PROMPTS = { select, text, password, confirm, isCancel };

// set: the secret question (default no), then the JSON value. A secret
// value is typed with hidden input. Null on cancel.
async function askValue(prompts) {
const secret = await prompts.confirm({ message: 'Store the value as a secret (encrypted)?', initialValue: false });
if (prompts.isCancel(secret)) return null;
const ask = secret === true ? prompts.password : prompts.text;
const value = await ask({ message: 'JSON value' });
return prompts.isCancel(value) ? null : { value, secret: secret === true };
}

/**
* The menu: ask for the action, the store and what the action needs.
* @param {{ select, text, password, confirm, isCancel }} [prompts] The prompt functions (for tests).
* @returns {Promise<Object|null>} The args, or null on cancel.
*/
export async function askKv(prompts = PROMPTS) {
const action = await prompts.select({ message: 'Operation', options: ACTIONS.map((value) => ({ value, label: value })) });
if (prompts.isCancel(action)) return null;
const store = await prompts.text({ message: 'Store name (from quant/meta.json)' });
if (prompts.isCancel(store)) return null;
const args = { local: true, action, store };
if (action === 'list') return args;
const key = await text({ message: 'Key' });
if (isCancel(key)) return null;
const value = action === 'set' ? await text({ message: 'JSON value' }) : undefined;
return isCancel(value) ? null : { ...args, key, value };
const key = await prompts.text({ message: 'Key' });
if (prompts.isCancel(key)) return null;
if (action !== 'set') return { ...args, key, value: undefined };
const answers = await askValue(prompts);
return answers === null ? null : { ...args, key, ...answers };
}

export default {
Expand All @@ -172,10 +203,11 @@ export default {
.option('index', { type: 'string', describe: 'list: index name' })
.option('equals', { type: 'string', array: true, describe: 'list: field=value for the index (repeat)' })
.option('if-version', { type: 'string', describe: 'set: write only if the key has this version' })
.option('org', { type: 'string', describe: 'Organisation (default: quant.json clientid, then local)' })
.option('project', { type: 'string', describe: 'Project (default: quant.json project, then dev)' })
.option('meta', { type: 'string', default: DEFAULT_FILES.meta, describe: 'Template metadata with KV stores' }),
promptArgs,
.option('secret', { type: 'boolean', describe: 'set: store the value encrypted (a list shows it masked)' })
.option('org', { type: 'string', describe: 'Organisation (default: quant/serve.json org, quant.json clientid, then local)' })
.option('project', { type: 'string', describe: 'Project (default: quant/serve.json project, quant.json project, then dev)' })
.option('meta', { type: 'string', describe: `Template metadata with KV stores (default: quant/serve.json meta, then ${DEFAULT_FILES.meta})` }),
promptArgs: () => askKv(),
async handler(args) {
const code = await runKv(args);
if (code !== 0) process.exit(code);
Expand Down
49 changes: 31 additions & 18 deletions src/commands/rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,19 @@ import axios from 'axios';
import { text, isCancel } from '@clack/prompts';
import { assertManifest } from '../helper/functions-manifest.js';
import { resolveRules } from '../helper/edge-rules-manifest.js';
import { portalOptions, oneLine } from '../serve/portal/client.js';

export { resolveRules };

export function rulesOptions(args, env = process.env) {
let saved = {};
try { saved = JSON.parse(fs.readFileSync('quant.json', 'utf8')); } catch { /* Optional project selection only. */ }
const customer = args.clientid || args.c || env.QUANT_CLIENT_ID || env.QUANT_CUSTOMER || saved.clientid;
const project = args.project || args.p || env.QUANT_PROJECT || saved.project;
// Deliberately never read QUANT_TOKEN, -t, saved.token, or upload endpoint.
const token = args['api-token'] || env.QUANT_API_TOKEN;
const base = args['api-base-url'] || env.QUANT_BASE_URL;
if (!customer || !project || !token || !base) throw new Error('Rules require customer, project, QUANT_API_TOKEN and QUANT_BASE_URL (portal /api/v2 URL)');
const url = new URL(base);
if (url.username || url.password || url.search || url.hash || !/\/api\/v2\/?$/.test(url.pathname) ||
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
throw new Error('Rules require an HTTPS portal /api/v2 URL (HTTP is allowed only for localhost)');
}
return { customer, project, token, base: base.replace(/\/$/, '') };
// Rules keep the token and base flags that the serve command does not have.
const merged = {
...env,
QUANT_API_TOKEN: args['api-token'] || env.QUANT_API_TOKEN,
QUANT_BASE_URL: args['api-base-url'] || env.QUANT_BASE_URL
};
const { customer, project, token } = portalOptions(args, merged);
return { customer, project, token, base: merged.QUANT_BASE_URL.replace(/\/$/, '') };
}

// Drift values printed in CI logs: matchers and placement only. Anything else
Expand Down Expand Up @@ -59,12 +54,30 @@ function refusalError(rules) {
return new Error('Managed rules were changed outside code; nothing was written. Re-run with --force to overwrite them with this manifest.');
}

const MESSAGE_STATUSES = [409, 422];

function adviceText(status, code) {
if (status === 409) return 'Rules are being changed by another request (409). Retry shortly.';
return `Rules deployment failed (${status || code || 'network error'}). Check portal URL, token scopes and project access.`;
}

// A 409 or 422 `message` replaces the advice: the API writes it for the user
// (for example, that the change may already be live, or that the rule order
// cannot be kept). A string `error` alone is printed above the advice.
function statusText(status, data, code) {
const advice = adviceText(status, code);
if (!MESSAGE_STATUSES.includes(status)) return advice;
const message = oneLine(data?.message);
if (message) return `Rules deployment failed (${status}): ${message}`;
const errorText = oneLine(data?.error);
return errorText ? `Rules deployment failed (${status}): ${errorText}\n${advice}` : advice;
}

// Never print Axios config/headers or an arbitrary upstream body containing credentials.
function deployError(error) {
const status = error.response?.status;
if (status === 409 && Array.isArray(error.response?.data?.rules)) return refusalError(error.response.data.rules);
if (status === 409) return new Error('Rules are being changed by another request (409). Retry shortly.');
return new Error(`Rules deployment failed (${status || error.code || 'network error'}). Check portal URL, token scopes and project access.`);
const { status, data } = error.response || {};
if (status === 409 && Array.isArray(data?.rules)) return refusalError(data.rules);
return new Error(statusText(status, data, error.code));
}

export default {
Expand Down
Loading
Loading