@polardbx/pxbaas 0.1.0-beta.3 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/dist/app/local-index.d.ts +70 -0
- package/dist/app/local-index.d.ts.map +1 -0
- package/dist/app/local-index.js +140 -0
- package/dist/app/local-index.js.map +1 -0
- package/dist/app/preflight.d.ts +12 -0
- package/dist/app/preflight.d.ts.map +1 -1
- package/dist/app/preflight.js +30 -17
- package/dist/app/preflight.js.map +1 -1
- package/dist/cli/root.d.ts.map +1 -1
- package/dist/cli/root.js +3 -0
- package/dist/cli/root.js.map +1 -1
- package/dist/commands/app/index.d.ts.map +1 -1
- package/dist/commands/app/index.js +398 -46
- package/dist/commands/app/index.js.map +1 -1
- package/dist/commands/completion/index.d.ts.map +1 -1
- package/dist/commands/completion/index.js +5 -1
- package/dist/commands/completion/index.js.map +1 -1
- package/dist/commands/data/index.d.ts +52 -0
- package/dist/commands/data/index.d.ts.map +1 -1
- package/dist/commands/data/index.js +201 -39
- package/dist/commands/data/index.js.map +1 -1
- package/dist/commands/db/index.d.ts +14 -0
- package/dist/commands/db/index.d.ts.map +1 -1
- package/dist/commands/db/index.js +126 -28
- package/dist/commands/db/index.js.map +1 -1
- package/dist/commands/functions/new.d.ts +4 -0
- package/dist/commands/functions/new.d.ts.map +1 -1
- package/dist/commands/functions/new.js +24 -9
- package/dist/commands/functions/new.js.map +1 -1
- package/dist/commands/login/index.d.ts.map +1 -1
- package/dist/commands/login/index.js +15 -30
- package/dist/commands/login/index.js.map +1 -1
- package/dist/commands/profile/import-env.d.ts +7 -2
- package/dist/commands/profile/import-env.d.ts.map +1 -1
- package/dist/commands/profile/import-env.js +141 -16
- package/dist/commands/profile/import-env.js.map +1 -1
- package/dist/commands/profile/index.d.ts.map +1 -1
- package/dist/commands/profile/index.js +13 -1
- package/dist/commands/profile/index.js.map +1 -1
- package/dist/commands/storage/index.d.ts.map +1 -1
- package/dist/commands/storage/index.js +209 -7
- package/dist/commands/storage/index.js.map +1 -1
- package/dist/commands/tenant/index.d.ts +19 -0
- package/dist/commands/tenant/index.d.ts.map +1 -0
- package/dist/commands/tenant/index.js +667 -0
- package/dist/commands/tenant/index.js.map +1 -0
- package/dist/commands/whoami/index.js +19 -0
- package/dist/commands/whoami/index.js.map +1 -1
- package/dist/credentials/credential-store.d.ts +30 -1
- package/dist/credentials/credential-store.d.ts.map +1 -1
- package/dist/credentials/credential-store.js +114 -12
- package/dist/credentials/credential-store.js.map +1 -1
- package/dist/credentials/env-file.d.ts +48 -7
- package/dist/credentials/env-file.d.ts.map +1 -1
- package/dist/credentials/env-file.js +91 -16
- package/dist/credentials/env-file.js.map +1 -1
- package/dist/credentials/jwt-introspect.d.ts +7 -0
- package/dist/credentials/jwt-introspect.d.ts.map +1 -1
- package/dist/credentials/jwt-introspect.js.map +1 -1
- package/dist/credentials/masker.d.ts +3 -0
- package/dist/credentials/masker.d.ts.map +1 -1
- package/dist/credentials/masker.js +3 -0
- package/dist/credentials/masker.js.map +1 -1
- package/dist/credentials/profile.d.ts +12 -0
- package/dist/credentials/profile.d.ts.map +1 -1
- package/dist/credentials/profile.js +23 -0
- package/dist/credentials/profile.js.map +1 -1
- package/dist/credentials/tty-guard.d.ts +10 -0
- package/dist/credentials/tty-guard.d.ts.map +1 -1
- package/dist/credentials/tty-guard.js +32 -0
- package/dist/credentials/tty-guard.js.map +1 -1
- package/dist/main.js +21 -2
- package/dist/main.js.map +1 -1
- package/dist/tenant/bundle.d.ts +66 -0
- package/dist/tenant/bundle.d.ts.map +1 -0
- package/dist/tenant/bundle.js +132 -0
- package/dist/tenant/bundle.js.map +1 -0
- package/dist/tenant/guide.d.ts +50 -0
- package/dist/tenant/guide.d.ts.map +1 -0
- package/dist/tenant/guide.js +152 -0
- package/dist/tenant/guide.js.map +1 -0
- package/dist/tenant/probe.d.ts +65 -0
- package/dist/tenant/probe.d.ts.map +1 -0
- package/dist/tenant/probe.js +159 -0
- package/dist/tenant/probe.js.map +1 -0
- package/dist/tenant/verify.d.ts +37 -0
- package/dist/tenant/verify.d.ts.map +1 -0
- package/dist/tenant/verify.js +132 -0
- package/dist/tenant/verify.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +65 -0
- package/dist/version.js.map +1 -0
- package/package.json +2 -2
- package/skills/pxbaas-app-spec/references/runtime-contract.md +1 -1
- package/skills/pxbaas-cli/SKILL.md +14 -5
- package/skills/pxbaas-cli/references/auth-and-credentials.md +19 -3
- package/skills/pxbaas-cli/references/data-postgrest.md +4 -3
- package/skills/pxbaas-cli/references/db-and-meta.md +5 -0
- package/skills/pxbaas-cli/references/safety-and-two-phase.md +42 -13
- package/skills/pxbaas-cli/references/tenant-admin.md +122 -0
- package/skills/pxbaas-cli/references/troubleshooting.md +4 -1
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pxbaas tenant` — 多租户实例的**平台运营侧**命令组。
|
|
3
|
+
*
|
|
4
|
+
* 与数据面命令的分工:`db` / `data` / `app` 等用的是租户级 anon / service_role key;
|
|
5
|
+
* 本命令组用的是实例 dashboard 的 HTTP Basic 凭据(DASHBOARD_USERNAME / PASSWORD),
|
|
6
|
+
* 二者存在凭据存储的两本独立账上(`credentials/<profile>` vs `admin/<profile>`),
|
|
7
|
+
* 数据面命令读不到管理凭据,管理命令也不需要租户密钥。
|
|
8
|
+
*
|
|
9
|
+
* 三条不可让的纪律:
|
|
10
|
+
* 1. 管理凭据只能来自 TTY 掩码输入或 stdin 管道,**绝不进 argv**(`--dashboard-*` 已被
|
|
11
|
+
* assertNoKeyArgs 拒绝)。
|
|
12
|
+
* 2. 输出的租户信息一律经驱动层脱敏投影,**永不含 `jwt_secret`**(母密钥);密钥值只在
|
|
13
|
+
* 写钥匙串或写 0600 交付包时经手,不打印、不进 `--json`。
|
|
14
|
+
* 3. 建租户 / 补密钥等写操作先预览再授权(`--dry-run` / `--yes` / `--confirm`)。
|
|
15
|
+
* 其中「同名软删租户会被复活并重置密钥」属破坏性动作,必须 `--reactivate` 显式确认。
|
|
16
|
+
*/
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import inquirer from 'inquirer';
|
|
19
|
+
import { TenantAdminDriver, TenantAdminError } from '@polardbx/pxbaas-client';
|
|
20
|
+
import { assertNoKeyArgs, assertTty, commitProfileCredentials, computeFingerprint, CredentialStore, formatError, formatSuccess, formatWarning, ProfileManager, readStdinLines, } from '../../credentials/index.js';
|
|
21
|
+
import { loadAdminContext, probeTenantAdmin } from '../../tenant/probe.js';
|
|
22
|
+
import { listHandoffs, writeBundle } from '../../tenant/bundle.js';
|
|
23
|
+
import { verifyTenantUsable } from '../../tenant/verify.js';
|
|
24
|
+
/** 与平台侧 sanitizeForIdentifier 的可用字符集保持交集,避免建出连库名都非法的租户。 */
|
|
25
|
+
const TENANT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
26
|
+
const isJson = (options, program) => Boolean(options.json || program.opts().json);
|
|
27
|
+
export function registerTenantCommands(program) {
|
|
28
|
+
const tenantCmd = program
|
|
29
|
+
.command('tenant')
|
|
30
|
+
.description('Platform-operator tenant management on a multi-tenant instance (dashboard credentials)');
|
|
31
|
+
tenantCmd
|
|
32
|
+
.command('login')
|
|
33
|
+
.description('Bind instance dashboard credentials to a profile (interactive by default; --user-stdin/--password-stdin for piped use)')
|
|
34
|
+
.option('--endpoint <url>', 'BaaS gateway URL (defaults to the current profile endpoint)')
|
|
35
|
+
.option('--user <name>', 'Dashboard username (not secret; the password must never be an argument)')
|
|
36
|
+
.option('--user-stdin', 'Read dashboard username from stdin (pair with --password-stdin)')
|
|
37
|
+
.option('--password-stdin', 'Read dashboard password from stdin (pair with --user-stdin)')
|
|
38
|
+
.option('--json', 'Output in JSON format')
|
|
39
|
+
.action(async (options) => {
|
|
40
|
+
try {
|
|
41
|
+
await handleTenantLogin(options, program);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
fail(error, isJson(options, program));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
tenantCmd
|
|
48
|
+
.command('logout')
|
|
49
|
+
.description('Remove the dashboard credentials bound to a profile (tenant keys are untouched)')
|
|
50
|
+
.option('--json', 'Output in JSON format')
|
|
51
|
+
.action(async (options) => {
|
|
52
|
+
try {
|
|
53
|
+
await handleTenantLogout(options, program);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
fail(error, isJson(options, program));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
tenantCmd
|
|
60
|
+
.command('probe')
|
|
61
|
+
.description('Read-only check: does this profile have the tenant admin channel?')
|
|
62
|
+
.option('--json', 'Output in JSON format')
|
|
63
|
+
.action(async (options) => {
|
|
64
|
+
try {
|
|
65
|
+
const ctx = await loadAdminContext({ profile: options.profile });
|
|
66
|
+
const result = await probeTenantAdmin(ctx);
|
|
67
|
+
if (isJson(options, program)) {
|
|
68
|
+
console.log(JSON.stringify({
|
|
69
|
+
status: result.status,
|
|
70
|
+
adminCapable: result.adminCapable,
|
|
71
|
+
endpoint: result.endpoint,
|
|
72
|
+
multiTenantMode: result.multiTenantMode,
|
|
73
|
+
tenantCount: result.tenants?.length,
|
|
74
|
+
detail: result.detail,
|
|
75
|
+
errorCode: result.errorCode,
|
|
76
|
+
}, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const badge = result.adminCapable ? formatSuccess('admin channel OK') : formatWarning(result.status);
|
|
80
|
+
console.log(`${badge} ${result.detail}`);
|
|
81
|
+
if (result.tenants?.length) {
|
|
82
|
+
console.log(` Tenants visible: ${result.tenants.length}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
fail(error, isJson(options, program));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
tenantCmd
|
|
90
|
+
.command('ls')
|
|
91
|
+
.description('List tenants on the instance (redacted: never includes key material)')
|
|
92
|
+
.option('--status <status>', 'Filter by active | suspended | deleting | deleted')
|
|
93
|
+
.option('--json', 'Output in JSON format')
|
|
94
|
+
.action(async (options) => {
|
|
95
|
+
try {
|
|
96
|
+
const ctx = await loadAdminContext({ profile: options.profile });
|
|
97
|
+
const driver = new TenantAdminDriver(ctx);
|
|
98
|
+
const result = await driver.list(options.status);
|
|
99
|
+
if (isJson(options, program)) {
|
|
100
|
+
console.log(JSON.stringify({ multiTenantMode: result.multiTenantMode, count: result.tenants.length, tenants: result.tenants }, null, 2));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (result.tenants.length === 0) {
|
|
104
|
+
console.log('No tenants on this instance. Create one: pxbaas tenant create <tenant-id>');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log(`${result.tenants.length} tenant(s):`);
|
|
108
|
+
console.log('');
|
|
109
|
+
for (const tenant of result.tenants) {
|
|
110
|
+
console.log(` ${tenant.tenantId} [${tenant.status}] plan=${tenant.plan ?? '-'} db=${tenant.dbName ?? '-'}` +
|
|
111
|
+
(tenant.hasAnonKey ? '' : ' ⚠ missing anon key (pxbaas tenant repair-keys)') +
|
|
112
|
+
(tenant.softDeleted ? ' (soft-deleted: same-name create would reset its keys)' : ''));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
fail(error, isJson(options, program));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
tenantCmd
|
|
120
|
+
.command('create [tenant-id]')
|
|
121
|
+
.description('Provision a new tenant (server generates jwt_secret + signs the per-tenant keys). Shows a plan and requires authorization.')
|
|
122
|
+
.option('--generate-id', 'Auto-generate a tenant id (t_<hex>) instead of passing one')
|
|
123
|
+
.option('--plan <plan>', 'Plan label passed to the platform', 'free')
|
|
124
|
+
.option('--display-name <name>', 'Display name (defaults to the tenant id)')
|
|
125
|
+
.option('--retention-days <n>', 'Pass through to the platform retention column')
|
|
126
|
+
.option('--reactivate', 'REQUIRED to reuse a soft-deleted tenant id (it resets that tenant’s jwt_secret and keys)')
|
|
127
|
+
.option('--dry-run', 'Show the plan without creating anything')
|
|
128
|
+
.option('--yes', 'Authorize non-interactively (skip the confirm prompt)')
|
|
129
|
+
.option('--as-profile [name]', 'Bind the new tenant keys into a profile (name defaults to the tenant id)')
|
|
130
|
+
.option('--bundle <file>', 'Write a 0600 tenant access bundle to hand to the end user')
|
|
131
|
+
.option('--intranet <url>', 'Also embed SUPABASE_INTRANET_URL in the bundle (same-VPC users)')
|
|
132
|
+
.option('--overwrite', 'Allow --bundle to replace an existing bundle file')
|
|
133
|
+
.option('--json', 'Output in JSON format')
|
|
134
|
+
.action(async (tenantId, options) => {
|
|
135
|
+
try {
|
|
136
|
+
await handleTenantCreate(tenantId, options, program);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
fail(error, isJson(options, program));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
tenantCmd
|
|
143
|
+
.command('keys <tenant-id>')
|
|
144
|
+
.description('Fetch a tenant’s per-tenant keys into the keyring (--as-profile) or an access bundle (--bundle). Never prints plaintext.')
|
|
145
|
+
.option('--as-profile [name]', 'Bind the keys into a profile (name defaults to the tenant id)')
|
|
146
|
+
.option('--bundle <file>', 'Write a 0600 tenant access bundle to hand to the end user')
|
|
147
|
+
.option('--intranet <url>', 'Also embed SUPABASE_INTRANET_URL in the bundle')
|
|
148
|
+
.option('--overwrite', 'Allow --bundle to replace an existing bundle file')
|
|
149
|
+
.option('--json', 'Output in JSON format')
|
|
150
|
+
.action(async (tenantId, options) => {
|
|
151
|
+
try {
|
|
152
|
+
await handleTenantKeys(tenantId, options, program);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
fail(error, isJson(options, program));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
tenantCmd
|
|
159
|
+
.command('repair-keys')
|
|
160
|
+
.description('Backfill the two API keys for tenants whose jwt_secret exists but keys are NULL (idempotent; not a rotation)')
|
|
161
|
+
.option('--confirm', 'Actually run the backfill (required)')
|
|
162
|
+
.option('--json', 'Output in JSON format')
|
|
163
|
+
.action(async (options) => {
|
|
164
|
+
try {
|
|
165
|
+
await handleTenantRepairKeys(options, program);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
fail(error, isJson(options, program));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
tenantCmd
|
|
172
|
+
.command('handoffs')
|
|
173
|
+
.description('List locally recorded access-bundle handoffs (fingerprints and hashes only, no key material)')
|
|
174
|
+
.option('--json', 'Output in JSON format')
|
|
175
|
+
.action(async (options) => {
|
|
176
|
+
try {
|
|
177
|
+
const records = await listHandoffs();
|
|
178
|
+
if (isJson(options, program)) {
|
|
179
|
+
console.log(JSON.stringify({ count: records.length, records }, null, 2));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (records.length === 0) {
|
|
183
|
+
console.log('No bundles have been generated from this machine yet.');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const record of records) {
|
|
187
|
+
console.log(` ${record.generatedAt} ${record.tenantId} anon=${record.anonFingerprint} service=${record.serviceFingerprint} → ${record.bundlePath}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
fail(error, isJson(options, program));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async function handleTenantLogin(options, program) {
|
|
196
|
+
// 口令绝不允许出现在 argv(--dashboard-password / --admin-password 亦被同一守卫拦截)
|
|
197
|
+
assertNoKeyArgs(process.argv);
|
|
198
|
+
const stdinMode = Boolean(options.userStdin) || Boolean(options.passwordStdin);
|
|
199
|
+
if (stdinMode && !(options.userStdin && options.passwordStdin)) {
|
|
200
|
+
throw new Error('Both --user-stdin and --password-stdin must be specified together');
|
|
201
|
+
}
|
|
202
|
+
let endpoint = options.endpoint;
|
|
203
|
+
let username;
|
|
204
|
+
let password;
|
|
205
|
+
const profiles = new ProfileManager();
|
|
206
|
+
const existing = await resolveTargetProfile(profiles, options, program);
|
|
207
|
+
if (stdinMode) {
|
|
208
|
+
if (!endpoint && !existing) {
|
|
209
|
+
throw new Error('--endpoint is required when using --user-stdin/--password-stdin and no profile to inherit it from');
|
|
210
|
+
}
|
|
211
|
+
const [userLine, passwordLine] = await readStdinLines(2, 'dashboard username on first line, dashboard password on second line');
|
|
212
|
+
username = userLine;
|
|
213
|
+
password = passwordLine;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
assertTty();
|
|
217
|
+
const answers = await inquirer.prompt([
|
|
218
|
+
{
|
|
219
|
+
type: 'input',
|
|
220
|
+
name: 'endpoint',
|
|
221
|
+
message: 'BaaS gateway URL:',
|
|
222
|
+
default: options.endpoint || existing?.endpoint || 'http://localhost:8000',
|
|
223
|
+
validate: (input) => {
|
|
224
|
+
if (!input.trim())
|
|
225
|
+
return 'Endpoint is required';
|
|
226
|
+
if (!input.startsWith('http://') && !input.startsWith('https://')) {
|
|
227
|
+
return 'Endpoint must start with http:// or https://';
|
|
228
|
+
}
|
|
229
|
+
// 管理面只能走网关:Studio 端口自身不鉴权,绕过去等于无认证取密钥。
|
|
230
|
+
if (/:(3100|3000|9999|5000|8085)\b/.test(input)) {
|
|
231
|
+
return 'Use the Kong gateway URL (typically :8000); component ports do not protect this API';
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
type: 'input',
|
|
238
|
+
name: 'username',
|
|
239
|
+
message: 'Dashboard username:',
|
|
240
|
+
default: options.user || '',
|
|
241
|
+
validate: (input) => (input.trim() ? true : 'Dashboard username is required'),
|
|
242
|
+
},
|
|
243
|
+
{ type: 'password', name: 'password', message: 'Dashboard password:', mask: '*' },
|
|
244
|
+
]);
|
|
245
|
+
endpoint = answers.endpoint;
|
|
246
|
+
username = answers.username;
|
|
247
|
+
password = answers.password;
|
|
248
|
+
}
|
|
249
|
+
endpoint = (endpoint ?? existing?.endpoint ?? 'http://localhost:8000').replace(/\/+$/, '');
|
|
250
|
+
if (!username || !password) {
|
|
251
|
+
throw new Error('Dashboard username and password are both required');
|
|
252
|
+
}
|
|
253
|
+
// 先探测再落库:存一把不通的管理口令,比拒绝登录更糟(后续每条命令都 401 且难归因)。
|
|
254
|
+
const probe = await probeTenantAdmin({ endpoint, username, password });
|
|
255
|
+
if (!probe.adminCapable) {
|
|
256
|
+
throw new Error(`Not saved. These dashboard credentials could not open the tenant admin channel (${probe.status}${probe.errorCode ? `, ${probe.errorCode}` : ''}): ${probe.detail}`);
|
|
257
|
+
}
|
|
258
|
+
const profileName = existing?.name ?? options.profile ?? program.opts().profile ?? 'default';
|
|
259
|
+
const profile = existing
|
|
260
|
+
? { ...existing, adminCapable: true, updatedAt: new Date().toISOString() }
|
|
261
|
+
: {
|
|
262
|
+
name: profileName,
|
|
263
|
+
endpoint,
|
|
264
|
+
// 管理专用 profile:不带租户级密钥,mode 保持 single-tenant 以免数据面命令误以为已就绪。
|
|
265
|
+
mode: 'single-tenant',
|
|
266
|
+
adminCapable: true,
|
|
267
|
+
createdAt: new Date().toISOString(),
|
|
268
|
+
updatedAt: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
if (!existing)
|
|
271
|
+
profile.endpoint = endpoint;
|
|
272
|
+
await profiles.setProfile(profile);
|
|
273
|
+
const store = new CredentialStore();
|
|
274
|
+
await store.setAdminCredentials(profileName, {
|
|
275
|
+
username,
|
|
276
|
+
password,
|
|
277
|
+
storedAt: new Date().toISOString(),
|
|
278
|
+
});
|
|
279
|
+
const output = {
|
|
280
|
+
status: 'success',
|
|
281
|
+
profile: profileName,
|
|
282
|
+
endpoint,
|
|
283
|
+
adminUser: maskUser(username),
|
|
284
|
+
adminPasswordFingerprint: computeFingerprint(password),
|
|
285
|
+
adminCapable: true,
|
|
286
|
+
multiTenantMode: probe.multiTenantMode,
|
|
287
|
+
tenantCount: probe.tenants?.length ?? 0,
|
|
288
|
+
...(existing ? {} : { note: 'Created an admin-only profile: it holds no tenant API keys, so data-plane commands on it will ask for pxbaas login.' }),
|
|
289
|
+
};
|
|
290
|
+
if (isJson(options, program)) {
|
|
291
|
+
console.log(JSON.stringify(output, null, 2));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
console.log(formatSuccess(`Dashboard credentials stored for profile: ${profileName}`));
|
|
295
|
+
console.log(` Endpoint: ${endpoint}`);
|
|
296
|
+
console.log(` Admin user: ${output.adminUser}`);
|
|
297
|
+
console.log(` Admin pass: ${output.adminPasswordFingerprint}`);
|
|
298
|
+
console.log(` Probe: ${probe.detail}`);
|
|
299
|
+
if (output.note)
|
|
300
|
+
console.log(formatWarning(output.note));
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* `tenant login` 允许把管理凭据绑到尚不存在的 profile,所以这里必须按名字查(查不到
|
|
304
|
+
* 返回 null)而不能退回 getCurrentProfile()——后者对 `--profile` 指向的未知名字是硬抛错。
|
|
305
|
+
* 尾置的 `--profile x` 由 commander 记在 program 上而非叶子上,两处都要看。
|
|
306
|
+
*/
|
|
307
|
+
async function resolveTargetProfile(profiles, options, program) {
|
|
308
|
+
const requested = options.profile ?? program.opts().profile;
|
|
309
|
+
if (requested)
|
|
310
|
+
return await profiles.getProfile(requested);
|
|
311
|
+
return await profiles.getCurrentProfile();
|
|
312
|
+
}
|
|
313
|
+
// --------------------------------------------------------------- tenant logout
|
|
314
|
+
async function handleTenantLogout(options, program) {
|
|
315
|
+
const profiles = new ProfileManager();
|
|
316
|
+
const profile = await profiles.getCurrentProfile();
|
|
317
|
+
if (!profile)
|
|
318
|
+
throw new Error('No active profile. Run "pxbaas login" or "pxbaas tenant login" first.');
|
|
319
|
+
const store = new CredentialStore();
|
|
320
|
+
const deleted = await store.deleteAdminCredentials(profile.name);
|
|
321
|
+
if (profile.adminCapable) {
|
|
322
|
+
await profiles.setProfile({ ...profile, adminCapable: false, updatedAt: new Date().toISOString() });
|
|
323
|
+
}
|
|
324
|
+
const output = { status: deleted ? 'success' : 'noop', profile: profile.name, adminCredentialsRemoved: deleted };
|
|
325
|
+
if (isJson(options, program)) {
|
|
326
|
+
console.log(JSON.stringify(output, null, 2));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
console.log(deleted
|
|
330
|
+
? formatSuccess(`Removed dashboard credentials for profile: ${profile.name}`)
|
|
331
|
+
: `Profile '${profile.name}' had no dashboard credentials.`);
|
|
332
|
+
}
|
|
333
|
+
async function handleTenantCreate(tenantIdArg, options, program) {
|
|
334
|
+
assertNoKeyArgs(process.argv);
|
|
335
|
+
const json = isJson(options, program);
|
|
336
|
+
const ctx = await loadAdminContext({ profile: options.profile });
|
|
337
|
+
const driver = new TenantAdminDriver(ctx);
|
|
338
|
+
const tenantId = resolveTenantId(tenantIdArg, options);
|
|
339
|
+
const retentionDays = parseRetentionDays(options.retentionDays);
|
|
340
|
+
// 判重:撞名在平台侧不是简单失败——软删同名的 create 会「复活并重置 jwt_secret + 两把键」,
|
|
341
|
+
// 等于把另一个使用者的凭据换掉。所以默认拒绝,复活必须显式。
|
|
342
|
+
let existing;
|
|
343
|
+
try {
|
|
344
|
+
existing = await driver.get(tenantId);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (!(error instanceof TenantAdminError) || error.code !== 'TENANT_NOT_FOUND')
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
if (existing) {
|
|
351
|
+
if (!existing.softDeleted) {
|
|
352
|
+
throw new Error(`Tenant '${tenantId}' already exists with status '${existing.status}'. Refusing to touch it. ` +
|
|
353
|
+
`Use "pxbaas tenant keys ${tenantId} --as-profile" to bind its existing keys, or pick another id.`);
|
|
354
|
+
}
|
|
355
|
+
if (!options.reactivate) {
|
|
356
|
+
throw new Error(`Tenant '${tenantId}' exists in soft-deleted state. Creating with this id would RESURRECT it and ` +
|
|
357
|
+
`REGENERATE its jwt_secret and both API keys (its previous holders lose access, and any of its data ` +
|
|
358
|
+
`that is still on the instance comes back under new credentials). ` +
|
|
359
|
+
`Pass --reactivate if that is really what you want, or pick another id.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const plan = {
|
|
363
|
+
tenantId,
|
|
364
|
+
action: existing ? 'reactivate' : 'create',
|
|
365
|
+
plan: options.plan,
|
|
366
|
+
displayName: options.displayName ?? tenantId,
|
|
367
|
+
expectedDbName: existing?.dbName ?? `tenant_${tenantId}`,
|
|
368
|
+
retentionDays,
|
|
369
|
+
resetsCredentials: Boolean(existing),
|
|
370
|
+
sideEffects: [
|
|
371
|
+
'tenant row in _platform.tenants (jwt_secret generated server-side)',
|
|
372
|
+
'CREATE DATABASE + CREATE USER + GRANT (platform treats failures here as non-fatal)',
|
|
373
|
+
'RLS metadata tables in the tenant database',
|
|
374
|
+
'private Storage bucket + Realtime registration',
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
if (options.dryRun) {
|
|
378
|
+
const preview = { status: 'dry-run', ...plan };
|
|
379
|
+
if (json)
|
|
380
|
+
console.log(JSON.stringify(preview, null, 2));
|
|
381
|
+
else
|
|
382
|
+
printPlan(preview);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const authorized = await authorize(plan, options);
|
|
386
|
+
if (!authorized) {
|
|
387
|
+
const refused = { status: 'refused', ...plan, note: 'Nothing was created.' };
|
|
388
|
+
if (json)
|
|
389
|
+
console.log(JSON.stringify(refused, null, 2));
|
|
390
|
+
else
|
|
391
|
+
console.log(formatWarning('Not authorized — nothing was created.'));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const created = await driver.create({
|
|
395
|
+
tenantId,
|
|
396
|
+
plan: options.plan,
|
|
397
|
+
displayName: options.displayName,
|
|
398
|
+
retentionDays,
|
|
399
|
+
});
|
|
400
|
+
// 建后验证:201 ≠ 可用(平台把建库/GRANT/RLS/Realtime 都当非致命步骤)。
|
|
401
|
+
const verification = created.keys.serviceRoleKey
|
|
402
|
+
? await verifyTenantUsable({
|
|
403
|
+
endpoint: ctx.endpoint,
|
|
404
|
+
serviceRoleKey: created.keys.serviceRoleKey,
|
|
405
|
+
realtimeProvisioned: created.realtimeProvisioned,
|
|
406
|
+
})
|
|
407
|
+
: undefined;
|
|
408
|
+
const bound = options.asProfile
|
|
409
|
+
? await bindTenantProfile(options.asProfile, tenantId, ctx.endpoint, created.keys)
|
|
410
|
+
: undefined;
|
|
411
|
+
const bundle = options.bundle
|
|
412
|
+
? await writeBundle(options.bundle, {
|
|
413
|
+
tenantId,
|
|
414
|
+
endpoint: ctx.endpoint,
|
|
415
|
+
anonKey: created.keys.anonKey ?? '',
|
|
416
|
+
serviceRoleKey: created.keys.serviceRoleKey ?? '',
|
|
417
|
+
intranetEndpoint: options.intranet,
|
|
418
|
+
}, { overwrite: options.overwrite })
|
|
419
|
+
: undefined;
|
|
420
|
+
const summary = {
|
|
421
|
+
status: created.realtimeProvisioned === false ? 'created-with-warnings' : 'created',
|
|
422
|
+
tenant: created.tenant,
|
|
423
|
+
keys: {
|
|
424
|
+
anonFingerprint: created.keys.anonKey ? computeFingerprint(created.keys.anonKey) : null,
|
|
425
|
+
serviceFingerprint: created.keys.serviceRoleKey ? computeFingerprint(created.keys.serviceRoleKey) : null,
|
|
426
|
+
},
|
|
427
|
+
realtimeProvisioned: created.realtimeProvisioned,
|
|
428
|
+
verification: verification ? verificationSummary(verification) : undefined,
|
|
429
|
+
profile: bound,
|
|
430
|
+
adminProfile: ctx.profileName,
|
|
431
|
+
bundle: bundle ? { path: bundle.path, bytes: bundle.bytes, sha256: bundle.record.bundleSha256 } : undefined,
|
|
432
|
+
};
|
|
433
|
+
if (json) {
|
|
434
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
console.log(formatSuccess(`Tenant '${tenantId}' ${existing ? 'reactivated' : 'created'} on ${ctx.endpoint}`));
|
|
438
|
+
console.log(` Status: ${created.tenant.status}`);
|
|
439
|
+
console.log(` Database: ${created.tenant.dbName ?? '-'}`);
|
|
440
|
+
console.log(` Anon key: ${summary.keys.anonFingerprint ?? '(none)'}`);
|
|
441
|
+
console.log(` Service key: ${summary.keys.serviceFingerprint ?? '(none)'}`);
|
|
442
|
+
if (verification)
|
|
443
|
+
printVerification(verification);
|
|
444
|
+
if (bound) {
|
|
445
|
+
console.log(formatSuccess(` Profile: ${bound} (multi-tenant, tenantId=${tenantId}, now current)`));
|
|
446
|
+
console.log(formatWarning(` Admin ops now need --profile ${ctx.profileName} (binding made '${bound}' current).`));
|
|
447
|
+
}
|
|
448
|
+
if (bundle) {
|
|
449
|
+
console.log('');
|
|
450
|
+
console.log(formatSuccess(` Bundle: ${bundle.path} (0600, ${bundle.bytes} bytes)`));
|
|
451
|
+
console.log('');
|
|
452
|
+
console.log(bundle.instructions);
|
|
453
|
+
}
|
|
454
|
+
if (!bound && !bundle) {
|
|
455
|
+
console.log('');
|
|
456
|
+
console.log(formatWarning(`Keys are not stored anywhere yet. Bind them locally with --as-profile, or hand them out with --bundle <file>.`));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// 验证不通过时以非零退出:管理员据此脚本化判断"这个租户真的能用吗"。
|
|
460
|
+
if (verification && !verification.ok)
|
|
461
|
+
process.exitCode = 2;
|
|
462
|
+
}
|
|
463
|
+
function resolveTenantId(arg, options) {
|
|
464
|
+
if (arg && options.generateId) {
|
|
465
|
+
throw new Error('Pass either a <tenant-id> or --generate-id, not both');
|
|
466
|
+
}
|
|
467
|
+
const id = (arg ?? (options.generateId ? `t_${randomBytes(4).toString('hex')}` : '')).trim();
|
|
468
|
+
if (!id) {
|
|
469
|
+
throw new Error('A tenant id is required (or use --generate-id)');
|
|
470
|
+
}
|
|
471
|
+
if (!TENANT_ID_RE.test(id)) {
|
|
472
|
+
throw new Error(`Invalid tenant id '${id}'. Must start with a letter or digit and contain only letters, digits, '_' or '-', max 64 chars.`);
|
|
473
|
+
}
|
|
474
|
+
return id;
|
|
475
|
+
}
|
|
476
|
+
function parseRetentionDays(value) {
|
|
477
|
+
if (value === undefined)
|
|
478
|
+
return undefined;
|
|
479
|
+
const parsed = Number(value);
|
|
480
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
481
|
+
throw new Error('--retention-days must be a non-negative integer');
|
|
482
|
+
}
|
|
483
|
+
return parsed;
|
|
484
|
+
}
|
|
485
|
+
function printPlan(plan) {
|
|
486
|
+
console.log(`Plan — tenant '${plan.tenantId}' (${plan.action})`);
|
|
487
|
+
console.log(` display_name: ${plan.displayName}`);
|
|
488
|
+
console.log(` plan: ${plan.plan}`);
|
|
489
|
+
console.log(` database: ${plan.expectedDbName ?? '-'} (name is assigned by the platform)`);
|
|
490
|
+
if (plan.retentionDays !== undefined)
|
|
491
|
+
console.log(` retention_days: ${plan.retentionDays}`);
|
|
492
|
+
if (plan.resetsCredentials) {
|
|
493
|
+
console.log(formatWarning(' ⚠ this id is soft-deleted; creating it regenerates that tenant’s jwt_secret and both keys.'));
|
|
494
|
+
}
|
|
495
|
+
console.log(' side effects:');
|
|
496
|
+
for (const effect of plan.sideEffects)
|
|
497
|
+
console.log(` - ${effect}`);
|
|
498
|
+
}
|
|
499
|
+
async function authorize(plan, options) {
|
|
500
|
+
if (options.yes)
|
|
501
|
+
return true;
|
|
502
|
+
if (!process.stdin.isTTY) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
const answers = await inquirer.prompt([
|
|
506
|
+
{
|
|
507
|
+
type: 'confirm',
|
|
508
|
+
name: 'go',
|
|
509
|
+
message: `Create tenant '${plan.tenantId}' (${plan.action}) on this instance?`,
|
|
510
|
+
default: false,
|
|
511
|
+
},
|
|
512
|
+
]);
|
|
513
|
+
return Boolean(answers.go);
|
|
514
|
+
}
|
|
515
|
+
async function bindTenantProfile(asProfile, tenantId, endpoint, keys) {
|
|
516
|
+
if (!keys.anonKey || !keys.serviceRoleKey) {
|
|
517
|
+
throw new Error(`Tenant '${tenantId}' came back without both keys. Run "pxbaas tenant repair-keys --confirm", then "pxbaas tenant keys ${tenantId} --as-profile".`);
|
|
518
|
+
}
|
|
519
|
+
const name = typeof asProfile === 'string' && asProfile ? asProfile : tenantId;
|
|
520
|
+
// 复用 login 的落库校验(JWT 过期 + 租户一致性 + 切当前),不自己写一份。
|
|
521
|
+
const result = await commitProfileCredentials({
|
|
522
|
+
profileName: name,
|
|
523
|
+
endpoint,
|
|
524
|
+
tenantId,
|
|
525
|
+
anonKey: keys.anonKey,
|
|
526
|
+
serviceRoleKey: keys.serviceRoleKey,
|
|
527
|
+
});
|
|
528
|
+
// 管理凭据与新绑定的租户 profile 是分开的两本账;这里只标记该 profile 曾由管理面签发。
|
|
529
|
+
const profiles = new ProfileManager();
|
|
530
|
+
const created = await profiles.getProfile(result.profileName);
|
|
531
|
+
if (created)
|
|
532
|
+
await profiles.setProfile({ ...created, adminCapable: true });
|
|
533
|
+
return result.profileName;
|
|
534
|
+
}
|
|
535
|
+
async function handleTenantKeys(tenantId, options, program) {
|
|
536
|
+
assertNoKeyArgs(process.argv);
|
|
537
|
+
const json = isJson(options, program);
|
|
538
|
+
const ctx = await loadAdminContext({ profile: options.profile });
|
|
539
|
+
const driver = new TenantAdminDriver(ctx);
|
|
540
|
+
const tenant = await driver.get(tenantId);
|
|
541
|
+
const keys = await driver.getKeys(tenantId);
|
|
542
|
+
if (!keys.anonKey && !keys.serviceRoleKey) {
|
|
543
|
+
throw new Error(`Tenant '${tenantId}' has no API keys stored (status=${tenant.status}). ` +
|
|
544
|
+
`Run "pxbaas tenant repair-keys --confirm" to backfill them from its jwt_secret, then retry.`);
|
|
545
|
+
}
|
|
546
|
+
// 交付包必须是完整的两把键:半成品包到了收件人手里只会变成一条难查的导入报错。
|
|
547
|
+
if (options.bundle && (!keys.anonKey || !keys.serviceRoleKey)) {
|
|
548
|
+
throw new Error(`Tenant '${tenantId}' is missing ${keys.anonKey ? 'the service role key' : 'the anon key'}; refusing to write a half-configured bundle. Run "pxbaas tenant repair-keys --confirm" first.`);
|
|
549
|
+
}
|
|
550
|
+
const bound = options.asProfile
|
|
551
|
+
? await bindTenantProfile(options.asProfile, tenantId, ctx.endpoint, keys)
|
|
552
|
+
: undefined;
|
|
553
|
+
const bundle = options.bundle
|
|
554
|
+
? await writeBundle(options.bundle, {
|
|
555
|
+
tenantId,
|
|
556
|
+
endpoint: ctx.endpoint,
|
|
557
|
+
anonKey: keys.anonKey,
|
|
558
|
+
serviceRoleKey: keys.serviceRoleKey,
|
|
559
|
+
intranetEndpoint: options.intranet,
|
|
560
|
+
}, { overwrite: options.overwrite })
|
|
561
|
+
: undefined;
|
|
562
|
+
if (!bound && !bundle) {
|
|
563
|
+
throw new Error(`Nothing to do: --as-profile or --bundle is required. Keys are never printed to the terminal — ` +
|
|
564
|
+
`the only supported destinations are the OS keyring (--as-profile) or a 0600 access bundle (--bundle <file>).`);
|
|
565
|
+
}
|
|
566
|
+
const summary = {
|
|
567
|
+
status: 'ok',
|
|
568
|
+
tenant,
|
|
569
|
+
keys: {
|
|
570
|
+
anonFingerprint: keys.anonKey ? computeFingerprint(keys.anonKey) : null,
|
|
571
|
+
serviceFingerprint: keys.serviceRoleKey ? computeFingerprint(keys.serviceRoleKey) : null,
|
|
572
|
+
},
|
|
573
|
+
profile: bound,
|
|
574
|
+
adminProfile: ctx.profileName,
|
|
575
|
+
bundle: bundle ? { path: bundle.path, bytes: bundle.bytes, sha256: bundle.record.bundleSha256 } : undefined,
|
|
576
|
+
};
|
|
577
|
+
if (json) {
|
|
578
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
console.log(`Tenant '${tenantId}' [${tenant.status}] plan=${tenant.plan ?? '-'} db=${tenant.dbName ?? '-'}`);
|
|
582
|
+
console.log(` Anon key: ${summary.keys.anonFingerprint ?? '(none)'}`);
|
|
583
|
+
console.log(` Service key: ${summary.keys.serviceFingerprint ?? '(none)'}`);
|
|
584
|
+
if (bound) {
|
|
585
|
+
console.log(formatSuccess(` Profile: ${bound} (tenantId=${tenantId}, now current)`));
|
|
586
|
+
console.log(formatWarning(` Admin ops now need --profile ${ctx.profileName} (binding made '${bound}' current).`));
|
|
587
|
+
}
|
|
588
|
+
if (bundle) {
|
|
589
|
+
console.log(formatSuccess(` Bundle: ${bundle.path} (0600, ${bundle.bytes} bytes)`));
|
|
590
|
+
console.log('');
|
|
591
|
+
console.log(bundle.instructions);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ------------------------------------------------------------ tenant repair-keys
|
|
595
|
+
async function handleTenantRepairKeys(options, program) {
|
|
596
|
+
const json = isJson(options, program);
|
|
597
|
+
const ctx = await loadAdminContext({ profile: options.profile });
|
|
598
|
+
const driver = new TenantAdminDriver(ctx);
|
|
599
|
+
const { tenants } = await driver.list();
|
|
600
|
+
const missing = tenants.filter((tenant) => !tenant.hasAnonKey || !tenant.hasServiceRoleKey);
|
|
601
|
+
if (!options.confirm) {
|
|
602
|
+
const preview = {
|
|
603
|
+
status: 'dry-run',
|
|
604
|
+
missingKeyCount: missing.length,
|
|
605
|
+
tenants: missing.map((tenant) => ({ tenantId: tenant.tenantId, status: tenant.status, hasAnonKey: tenant.hasAnonKey, hasServiceRoleKey: tenant.hasServiceRoleKey })),
|
|
606
|
+
note: 'This re-signs keys from each tenant’s EXISTING jwt_secret. It is idempotent and does not revoke anything already issued. Pass --confirm to run.',
|
|
607
|
+
};
|
|
608
|
+
if (json)
|
|
609
|
+
console.log(JSON.stringify(preview, null, 2));
|
|
610
|
+
else {
|
|
611
|
+
console.log(`Tenants missing API keys: ${missing.length}`);
|
|
612
|
+
for (const tenant of missing)
|
|
613
|
+
console.log(` ${tenant.tenantId} anon=${tenant.hasAnonKey ? 'ok' : 'MISSING'} service=${tenant.hasServiceRoleKey ? 'ok' : 'MISSING'}`);
|
|
614
|
+
console.log(formatWarning(preview.note ?? ''));
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (missing.length === 0) {
|
|
619
|
+
const noop = { status: 'noop', updated: 0, note: 'Every visible tenant already has both keys.' };
|
|
620
|
+
if (json)
|
|
621
|
+
console.log(JSON.stringify(noop, null, 2));
|
|
622
|
+
else
|
|
623
|
+
console.log(noop.note ?? '');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const result = await driver.repairKeys();
|
|
627
|
+
const output = { status: 'repaired', updated: result.updated, message: result.message, tenants: missing.map((t) => t.tenantId) };
|
|
628
|
+
if (json)
|
|
629
|
+
console.log(JSON.stringify(output, null, 2));
|
|
630
|
+
else
|
|
631
|
+
console.log(formatSuccess(`Backfilled keys for ${result.updated} tenant(s): ${missing.map((t) => t.tenantId).join(', ')}`));
|
|
632
|
+
}
|
|
633
|
+
// ------------------------------------------------------------------ formatting
|
|
634
|
+
function verificationSummary(verification) {
|
|
635
|
+
return {
|
|
636
|
+
ok: verification.ok,
|
|
637
|
+
dbName: verification.dbName,
|
|
638
|
+
steps: verification.steps.map((step) => ({ name: step.name, status: step.status, detail: step.detail })),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function printVerification(verification) {
|
|
642
|
+
const icon = { pass: '✔', fail: '✗', skip: '-' };
|
|
643
|
+
console.log(' Post-create verification:');
|
|
644
|
+
for (const step of verification.steps) {
|
|
645
|
+
console.log(` ${icon[step.status]} ${step.name}: ${step.detail}`);
|
|
646
|
+
if (step.remediation)
|
|
647
|
+
console.log(` → ${step.remediation}`);
|
|
648
|
+
}
|
|
649
|
+
if (!verification.ok) {
|
|
650
|
+
console.log(formatWarning('The tenant was accepted by the platform but is not fully usable. The provisioning steps above are non-fatal server-side; fix the failing item before handing keys out.'));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function maskUser(username) {
|
|
654
|
+
if (username.length <= 2)
|
|
655
|
+
return '**';
|
|
656
|
+
return `${username.slice(0, 2)}***${username.slice(-1)}`;
|
|
657
|
+
}
|
|
658
|
+
function fail(error, json) {
|
|
659
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
660
|
+
const code = error instanceof TenantAdminError ? error.code : 'CLI_ERROR';
|
|
661
|
+
if (json)
|
|
662
|
+
console.log(JSON.stringify({ code, message }, null, 2));
|
|
663
|
+
else
|
|
664
|
+
console.error(formatError(`${code}: ${message}`));
|
|
665
|
+
process.exit(1);
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=index.js.map
|