@dooer/dooer-test-env 1.13.0 → 1.15.0
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/lib/cli.js +36 -19
- package/lib/command/partner.js +97 -0
- package/lib/command/remote-environment.js +281 -0
- package/lib/command/target.js +34 -0
- package/lib/command/user.js +148 -0
- package/lib/remote.js +357 -0
- package/lib/users.js +279 -0
- package/package.json +2 -1
- package/readme.md +107 -4
package/lib/cli.js
CHANGED
|
@@ -6,23 +6,40 @@ const pkg = require('../package.json')
|
|
|
6
6
|
// customers), copy/purge customers between environments, and shred — see ENVIRONMENT-PLAN.md.
|
|
7
7
|
// Command groups are wired as modules under ./command.
|
|
8
8
|
module.exports = async function cli() {
|
|
9
|
-
return
|
|
10
|
-
.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
9
|
+
return (
|
|
10
|
+
yargs(hideBin(process.argv))
|
|
11
|
+
.scriptName('dooer-test-env')
|
|
12
|
+
.usage('$0 <group> <command> [options]')
|
|
13
|
+
.version(pkg.version)
|
|
14
|
+
.command(require('./command/setup')) // prerequisites + registry login + compose generation
|
|
15
|
+
.command(require('./command/env')) // up / down / start / stop / status
|
|
16
|
+
.command(require('./command/service')) // service start|stop|restart|deploy|local|unlocal|version
|
|
17
|
+
.command(require('./command/db')) // db build|pull|snapshot|rollback
|
|
18
|
+
.command(require('./command/customer')) // customer copy|purge
|
|
19
|
+
.command(require('./command/remote-environment')) // login/status/logout/list against a public GraphQL API
|
|
20
|
+
.command(require('./command/user')) // user search|get|create|partners
|
|
21
|
+
.command(require('./command/partner')) // partner list|users|assign|unassign
|
|
22
|
+
.command(require('./command/shred')) // shred (localhost only)
|
|
23
|
+
.command(require('./command/bankid')) // bankid pull|status|clear (staging certs → keychain)
|
|
24
|
+
.command(require('./command/validation')) // validation on|off|status (output-schema checks)
|
|
25
|
+
.command(require('./command/logs')) // search logs across all local services (transactionId, errors, …)
|
|
26
|
+
.command(require('./command/measure')) // resource-usage report for the running env
|
|
27
|
+
.demandCommand(1, 'Pick a command group. Try --help.')
|
|
28
|
+
.strict()
|
|
29
|
+
// yargs prints the full usage block for a HANDLER failure too, which buries the actual message under
|
|
30
|
+
// an option list that has nothing to do with it (a permission error is not a usage error). Print the
|
|
31
|
+
// usage only when yargs itself rejected the arguments; otherwise just the message.
|
|
32
|
+
.fail((msg, err, yargsInstance) => {
|
|
33
|
+
if (!err) {
|
|
34
|
+
console.error(yargsInstance.help())
|
|
35
|
+
console.error(`\n${msg}\n`)
|
|
36
|
+
} else {
|
|
37
|
+
console.error(`\n${err.message}\n`)
|
|
38
|
+
}
|
|
39
|
+
process.exit(1)
|
|
40
|
+
})
|
|
41
|
+
.help()
|
|
42
|
+
.wrap(Math.min(120, yargs().terminalWidth()))
|
|
43
|
+
.parseAsync()
|
|
44
|
+
)
|
|
28
45
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const u = require('../users')
|
|
3
|
+
const remote = require('../remote')
|
|
4
|
+
const { envOption, writeOption, printTable } = require('./target')
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
command: 'partner <command>',
|
|
8
|
+
describe: 'list partners and manage their user membership (local env by default)',
|
|
9
|
+
builder: (y) =>
|
|
10
|
+
y
|
|
11
|
+
.command({
|
|
12
|
+
command: 'list',
|
|
13
|
+
describe: 'list every partner in the environment',
|
|
14
|
+
builder: envOption,
|
|
15
|
+
handler: async (argv) => {
|
|
16
|
+
const env = remote.resolveEnv(argv.env)
|
|
17
|
+
const rows = await u.listPartners(env)
|
|
18
|
+
console.log(chalk.bold(`\n${rows.length} partner(s) in ${env.label}\n`))
|
|
19
|
+
printTable(rows, [
|
|
20
|
+
['NAME', (r) => r.name],
|
|
21
|
+
['DOMAIN', (r) => r.domain],
|
|
22
|
+
['STATUS', (r) => r.status],
|
|
23
|
+
['ID', (r) => r.id],
|
|
24
|
+
])
|
|
25
|
+
console.log()
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
.command({
|
|
29
|
+
command: 'users <partner>',
|
|
30
|
+
describe: 'list the users of one partner and their level',
|
|
31
|
+
builder: (y2) =>
|
|
32
|
+
envOption(y2).positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
|
|
33
|
+
handler: async (argv) => {
|
|
34
|
+
const env = remote.resolveEnv(argv.env)
|
|
35
|
+
const partner = await u.findPartner(env, argv.partner)
|
|
36
|
+
const rows = await u.partnerUsers(env, partner.id)
|
|
37
|
+
console.log(chalk.bold(`\n${partner.name} — ${rows.length} user(s)`) + ` (${env.label})\n`)
|
|
38
|
+
printTable(rows, [
|
|
39
|
+
['EMAIL', (r) => r.user && r.user.email],
|
|
40
|
+
['NAME', (r) => (r.user ? `${r.user.firstName} ${r.user.lastName}` : null)],
|
|
41
|
+
['LEVEL', (r) => r.role],
|
|
42
|
+
['STATUS', (r) => r.status],
|
|
43
|
+
['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
|
|
44
|
+
['ID', (r) => r.userId],
|
|
45
|
+
])
|
|
46
|
+
console.log()
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
.command({
|
|
50
|
+
command: 'assign <user> <partner>',
|
|
51
|
+
describe: 'add a user to a partner (or change their level). Dry-run by default.',
|
|
52
|
+
builder: (y2) =>
|
|
53
|
+
writeOption(envOption(y2))
|
|
54
|
+
.positional('user', { type: 'string', describe: 'user id or email' })
|
|
55
|
+
.positional('partner', { type: 'string', describe: 'partner id, name or domain' })
|
|
56
|
+
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level within the partner' }),
|
|
57
|
+
handler: async (argv) => {
|
|
58
|
+
const env = remote.resolveEnv(argv.env)
|
|
59
|
+
const user = await u.findUser(env, argv.user)
|
|
60
|
+
const partner = await u.findPartner(env, argv.partner)
|
|
61
|
+
// The API refuses a non-`hi` user (invalid-user-type); say so before spending a round trip.
|
|
62
|
+
if (user.userType !== 'hi') {
|
|
63
|
+
throw new Error(`only "hi" users can belong to a partner — ${user.email} is "${user.userType}"`)
|
|
64
|
+
}
|
|
65
|
+
if (argv.execute) await u.assignPartner(env, { userId: user.id, partnerId: partner.id, level: argv.level })
|
|
66
|
+
console.log(
|
|
67
|
+
(argv.execute ? chalk.green(`\nset ${argv.level}`) : chalk.yellow(`\nDRY RUN — would set ${argv.level}`)) +
|
|
68
|
+
`: ${user.email} @ ${partner.name} (${env.label})`
|
|
69
|
+
)
|
|
70
|
+
if (!argv.execute) console.log(' Re-run with --execute to apply.')
|
|
71
|
+
console.log()
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
.command({
|
|
75
|
+
command: 'unassign <user> <partner>',
|
|
76
|
+
describe: 'remove a user from a partner. Dry-run by default.',
|
|
77
|
+
builder: (y2) =>
|
|
78
|
+
writeOption(envOption(y2))
|
|
79
|
+
.positional('user', { type: 'string', describe: 'user id or email' })
|
|
80
|
+
.positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
|
|
81
|
+
handler: async (argv) => {
|
|
82
|
+
const env = remote.resolveEnv(argv.env)
|
|
83
|
+
const user = await u.findUser(env, argv.user)
|
|
84
|
+
const partner = await u.findPartner(env, argv.partner)
|
|
85
|
+
if (argv.execute) await u.unassignPartner(env, { userId: user.id, partnerId: partner.id })
|
|
86
|
+
console.log(
|
|
87
|
+
(argv.execute ? chalk.green('\nremoved') : chalk.yellow('\nDRY RUN — would remove')) +
|
|
88
|
+
` ${user.email} from ${partner.name} (${env.label})`
|
|
89
|
+
)
|
|
90
|
+
if (!argv.execute) console.log(' Re-run with --execute to apply.')
|
|
91
|
+
console.log()
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
.demandCommand(1, 'Use: partner list | partner users | partner assign | partner unassign')
|
|
95
|
+
.strict(),
|
|
96
|
+
handler: () => {},
|
|
97
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
const readline = require('readline')
|
|
2
|
+
const chalk = require('chalk')
|
|
3
|
+
const qrcode = require('qrcode-terminal')
|
|
4
|
+
const remote = require('../remote')
|
|
5
|
+
|
|
6
|
+
// Log in to a remote Dooer environment over its public GraphQL endpoint and keep the session in the
|
|
7
|
+
// keychain, so the other commands (user / partner) can act as you without any cluster access.
|
|
8
|
+
|
|
9
|
+
// Everything this command prints is information you need to read, so it goes out in the terminal's own
|
|
10
|
+
// foreground colour. chalk.gray renders as near-invisible on a dark profile (Jimmy 2026-09-04).
|
|
11
|
+
const plain = (s) => s
|
|
12
|
+
|
|
13
|
+
const envOption = (y) =>
|
|
14
|
+
y.option('env', {
|
|
15
|
+
type: 'string',
|
|
16
|
+
default: 'local',
|
|
17
|
+
describe: `environment: ${remote.envNames().join(' | ')}`,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// ── prompts ─────────────────────────────────────────────────────────────────
|
|
21
|
+
function ask(question) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
if (!process.stdin.isTTY) {
|
|
24
|
+
reject(new Error(`need a terminal to ask for "${question.trim()}" — pass it as a flag instead`))
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
28
|
+
rl.question(question, (answer) => {
|
|
29
|
+
rl.close()
|
|
30
|
+
resolve(answer.trim())
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Hidden input by reading raw keystrokes rather than overriding readline's internal `_writeToOutput`.
|
|
36
|
+
// That override races with input that arrives before the prompt is drawn and leaves the line half-erased;
|
|
37
|
+
// raw mode simply never echoes, which is both simpler and what it looks like it does.
|
|
38
|
+
function askHidden(question) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const { stdin, stdout } = process
|
|
41
|
+
if (!stdin.isTTY) {
|
|
42
|
+
reject(new Error(`need a terminal to ask for "${question.trim()}" — pass it as a flag instead`))
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
stdout.write(question)
|
|
46
|
+
let value = ''
|
|
47
|
+
const wasRaw = stdin.isRaw
|
|
48
|
+
stdin.setRawMode(true)
|
|
49
|
+
stdin.resume()
|
|
50
|
+
stdin.setEncoding('utf8')
|
|
51
|
+
const done = (err, out) => {
|
|
52
|
+
stdin.removeListener('data', onData)
|
|
53
|
+
stdin.setRawMode(wasRaw)
|
|
54
|
+
stdin.pause()
|
|
55
|
+
stdout.write('\n')
|
|
56
|
+
err ? reject(err) : resolve(out)
|
|
57
|
+
}
|
|
58
|
+
const onData = (chunk) => {
|
|
59
|
+
for (const ch of chunk) {
|
|
60
|
+
if (ch === '\r' || ch === '\n' || ch === '') return done(null, value)
|
|
61
|
+
if (ch === '') return done(new Error('cancelled')) // Ctrl-C
|
|
62
|
+
if (ch === '' || ch === '\b') value = value.slice(0, -1)
|
|
63
|
+
else if (ch >= ' ') value += ch
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
stdin.on('data', onData)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── terminal QR ─────────────────────────────────────────────────────────────
|
|
71
|
+
// The BankID payload changes every second, so the QR is redrawn in place: render, remember how many lines
|
|
72
|
+
// it occupied, then move the cursor back up over them before the next frame. Falls back to plain appending
|
|
73
|
+
// when stdout is not a terminal (piped output, CI) so the log stays readable.
|
|
74
|
+
function qrRenderer() {
|
|
75
|
+
let lastLines = 0
|
|
76
|
+
return (payload, statusLine) => {
|
|
77
|
+
qrcode.generate(payload, { small: true }, (art) => {
|
|
78
|
+
const block = `${art}\n${statusLine}\n`
|
|
79
|
+
const lines = block.split('\n').length - 1
|
|
80
|
+
if (process.stdout.isTTY && lastLines) process.stdout.write(`[${lastLines}A[0J`)
|
|
81
|
+
process.stdout.write(block)
|
|
82
|
+
lastLines = process.stdout.isTTY ? lines : 0
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── shared: store a token and report who it belongs to ──────────────────────
|
|
88
|
+
// Detail lines are printed in the terminal's default colour: chalk.gray renders as near-invisible on a
|
|
89
|
+
// dark profile, and everything below is information you need to read (Jimmy 2026-09-04).
|
|
90
|
+
const detail = (k, v) => console.log(` ${chalk.cyan(k.padEnd(12))}${v}`)
|
|
91
|
+
|
|
92
|
+
async function storeAndReport(env, token) {
|
|
93
|
+
remote.tokenSet(env.name, token)
|
|
94
|
+
const me = await remote.currentUser(env, token)
|
|
95
|
+
const exp = remote.expiry(token)
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.green(`\nlogged in to ${env.label}`) +
|
|
98
|
+
(me ? ` as ${chalk.bold(`${me.firstName} ${me.lastName}`)} <${me.email}>` : '')
|
|
99
|
+
)
|
|
100
|
+
if (me) {
|
|
101
|
+
detail('user id', me.id)
|
|
102
|
+
// Which of your accounts this is. `admin` is required to create users; partner operations need a
|
|
103
|
+
// partner-level session; a `customer` token can do neither.
|
|
104
|
+
detail('user type', me.userType === 'admin' ? chalk.bold(me.userType) : me.userType)
|
|
105
|
+
}
|
|
106
|
+
detail('session', `stored in keychain${exp ? `, valid until ${exp.at.toISOString()}` : ''}`)
|
|
107
|
+
detail('check with', `dooer-test-env remote-environment status --env ${env.name}`)
|
|
108
|
+
console.log()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
command: 'remote-environment <command>',
|
|
113
|
+
aliases: ['remote'],
|
|
114
|
+
describe: 'log in to an environment through its public GraphQL API (session kept in the keychain)',
|
|
115
|
+
builder: (y) =>
|
|
116
|
+
y
|
|
117
|
+
.command({
|
|
118
|
+
command: 'login',
|
|
119
|
+
describe: 'log in with BankID (default) or email + password',
|
|
120
|
+
builder: (y2) =>
|
|
121
|
+
envOption(y2)
|
|
122
|
+
.option('bankid', { type: 'boolean', describe: 'log in with BankID (default when no --email)' })
|
|
123
|
+
.option('customer', {
|
|
124
|
+
type: 'boolean',
|
|
125
|
+
describe: 'BankID: sign in as your CUSTOMER account instead of your professional one',
|
|
126
|
+
})
|
|
127
|
+
.option('personnummer', { type: 'string', describe: 'BankID: your personnummer (default: any, via QR)' })
|
|
128
|
+
.option('email', { type: 'string', describe: 'log in with this email + password instead of BankID' })
|
|
129
|
+
.option('password', { type: 'string', describe: 'password (omit to be prompted, which is safer)' }),
|
|
130
|
+
handler: async (argv) => {
|
|
131
|
+
const env = remote.resolveEnv(argv.env)
|
|
132
|
+
const usePassword = Boolean(argv.email || argv.password) && !argv.bankid
|
|
133
|
+
|
|
134
|
+
if (usePassword) {
|
|
135
|
+
const email = argv.email || (await ask('email: '))
|
|
136
|
+
const password = argv.password || (await askHidden('password: '))
|
|
137
|
+
console.log(plain(`\nauthenticating against ${env.url} …`))
|
|
138
|
+
await storeAndReport(env, await remote.loginWithPassword(env, email, password))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Default to HQ's flow — the partner authentication session, which resolves your professional
|
|
143
|
+
// (`hi`) account. `loginWithBankIdV2` is the other route, but service-accounts pins it to
|
|
144
|
+
// user_type 'customer' (lib/login-with-bank-id.js:199), so it can only ever return that account.
|
|
145
|
+
const asCustomer = Boolean(argv.customer)
|
|
146
|
+
console.log(
|
|
147
|
+
chalk.bold(`\nBankID login — ${env.label}`) +
|
|
148
|
+
plain(
|
|
149
|
+
`\n${env.url}\n` +
|
|
150
|
+
(asCustomer ? 'signing in as your customer account\n' : '') +
|
|
151
|
+
`\nOpen the BankID app and scan this code (it refreshes every second):\n`
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
const draw = qrRenderer()
|
|
155
|
+
const onFrame = ({ qr, hintCode }) => {
|
|
156
|
+
if (!qr) return
|
|
157
|
+
draw(qr, plain(` ${remote.HINTS[hintCode] || hintCode || 'starting…'} (Ctrl-C to abort)`))
|
|
158
|
+
}
|
|
159
|
+
const token = asCustomer
|
|
160
|
+
? await remote.loginWithBankId(env, { sePersonnummer: argv.personnummer, onFrame })
|
|
161
|
+
: await remote.loginWithPartnerBankId(env, { idNumber: argv.personnummer, onFrame })
|
|
162
|
+
await storeAndReport(env, token)
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
.command({
|
|
166
|
+
command: 'become [user]',
|
|
167
|
+
describe: 'as an ADMIN, assume a partner user to get a partner-level session (back-office “Become user”)',
|
|
168
|
+
builder: (y2) =>
|
|
169
|
+
envOption(y2)
|
|
170
|
+
.positional('user', { type: 'string', describe: 'user id or email of the partner user' })
|
|
171
|
+
.option('partner', { type: 'string', describe: 'which partner (needed only if they belong to several)' })
|
|
172
|
+
.option('revert', { type: 'boolean', describe: 'restore the session you had before becoming' }),
|
|
173
|
+
handler: async (argv) => {
|
|
174
|
+
const env = remote.resolveEnv(argv.env)
|
|
175
|
+
const u = require('../users')
|
|
176
|
+
|
|
177
|
+
if (argv.revert) {
|
|
178
|
+
const restored = remote.unstashToken(env.name)
|
|
179
|
+
if (!restored) throw new Error(`no stashed session for ${env.name} — nothing to revert to`)
|
|
180
|
+
const me = await remote.currentUser(env, restored)
|
|
181
|
+
console.log(chalk.green(`\nback to ${me.email} (${me.userType}) on ${env.label}\n`))
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!argv.user) throw new Error('which user? pass a user id or email (or --revert)')
|
|
186
|
+
const user = await u.findUser(env, argv.user)
|
|
187
|
+
const partners = await u.partnersForUser(env, user.id)
|
|
188
|
+
if (!partners.length) throw new Error(`${user.email} does not belong to any partner`)
|
|
189
|
+
const partner = argv.partner
|
|
190
|
+
? await u.findPartner(env, argv.partner)
|
|
191
|
+
: partners.length === 1
|
|
192
|
+
? partners[0]
|
|
193
|
+
: null
|
|
194
|
+
if (!partner) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${user.email} belongs to ${partners.length} partners — pick one with --partner:\n` +
|
|
197
|
+
partners.map((p) => ` ${p.name} (${p.domain})`).join('\n')
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
// Keep the admin token: becoming replaces the session, and you will want it back.
|
|
201
|
+
remote.stashToken(env.name)
|
|
202
|
+
const { token } = await remote.becomePartnerUser(env, { partnerId: partner.id, userId: user.id })
|
|
203
|
+
await storeAndReport(env, token)
|
|
204
|
+
console.log(` Revert with: dooer-test-env remote-environment become --revert --env ${env.name}\n`)
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
.command({
|
|
208
|
+
command: 'status',
|
|
209
|
+
describe: 'who you are logged in as, and until when',
|
|
210
|
+
builder: (y2) => envOption(y2).option('all', { type: 'boolean', describe: 'report every environment' }),
|
|
211
|
+
handler: async (argv) => {
|
|
212
|
+
const names = argv.all ? remote.envNames() : [remote.resolveEnv(argv.env).name]
|
|
213
|
+
console.log()
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
const env = remote.resolveEnv(name)
|
|
216
|
+
const token = remote.tokenGet(name)
|
|
217
|
+
const head = `${chalk.bold(env.label.padEnd(22))}`
|
|
218
|
+
if (!token) {
|
|
219
|
+
// The local stack needs no login — it is signed with the shared dev keypair this CLI holds.
|
|
220
|
+
const note =
|
|
221
|
+
name === 'local' ? plain('no session — uses a minted service token') : chalk.yellow('not logged in')
|
|
222
|
+
console.log(`${head}${note}`)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
const exp = remote.expiry(token)
|
|
226
|
+
if (exp && exp.expired) {
|
|
227
|
+
console.log(`${head}${chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)}`)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
let me = null
|
|
231
|
+
try {
|
|
232
|
+
me = await remote.currentUser(env, token)
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.log(`${head}${chalk.red(`session rejected — ${e.message}`)}`)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
console.log(
|
|
238
|
+
`${head}${chalk.green(me ? `${me.firstName} ${me.lastName} <${me.email}>` : 'valid session')}` +
|
|
239
|
+
plain(exp ? ` until ${exp.at.toISOString()}` : '')
|
|
240
|
+
)
|
|
241
|
+
// The account type decides what this session may do, so it belongs in the whoami line.
|
|
242
|
+
if (me) console.log(`${''.padEnd(22)}${me.id} ${chalk.cyan(me.userType || 'unknown type')}`)
|
|
243
|
+
}
|
|
244
|
+
console.log()
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
.command({
|
|
248
|
+
command: 'logout',
|
|
249
|
+
describe: 'forget the stored session for an environment',
|
|
250
|
+
builder: (y2) => envOption(y2),
|
|
251
|
+
handler: (argv) => {
|
|
252
|
+
const env = remote.resolveEnv(argv.env)
|
|
253
|
+
const had = remote.tokenDelete(env.name)
|
|
254
|
+
console.log(had ? chalk.green(`\nforgot the ${env.label} session\n`) : plain(`\nno ${env.label} session\n`))
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
.command({
|
|
258
|
+
command: 'list',
|
|
259
|
+
describe: 'the environments this CLI knows and where they point',
|
|
260
|
+
handler: () => {
|
|
261
|
+
console.log()
|
|
262
|
+
for (const name of remote.envNames()) {
|
|
263
|
+
const env = remote.resolveEnv(name)
|
|
264
|
+
const token = remote.tokenGet(name)
|
|
265
|
+
const exp = token ? remote.expiry(token) : null
|
|
266
|
+
const state = !token
|
|
267
|
+
? name === 'local'
|
|
268
|
+
? plain('service token')
|
|
269
|
+
: chalk.yellow('not logged in')
|
|
270
|
+
: exp && exp.expired
|
|
271
|
+
? chalk.yellow('expired')
|
|
272
|
+
: chalk.green('logged in')
|
|
273
|
+
console.log(` ${name.padEnd(12)}${env.url.padEnd(36)}${state}`)
|
|
274
|
+
}
|
|
275
|
+
console.log()
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
.demandCommand(1, 'Use: remote-environment login | status | logout | list')
|
|
279
|
+
.strict(),
|
|
280
|
+
handler: () => {},
|
|
281
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const remote = require('../remote')
|
|
3
|
+
|
|
4
|
+
// Shared bits for the environment-addressed commands (user / partner). These reach an environment through
|
|
5
|
+
// its PUBLIC GraphQL endpoint, so the target is an ENVIRONMENT, not a k8s namespace — same `--env` word
|
|
6
|
+
// the remote-environment commands use. The local stack is the default.
|
|
7
|
+
const envOption = (y) =>
|
|
8
|
+
y.option('env', {
|
|
9
|
+
type: 'string',
|
|
10
|
+
default: 'local',
|
|
11
|
+
describe: `environment: ${remote.envNames().join(' | ')}`,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const writeOption = (y) =>
|
|
15
|
+
y.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
|
|
16
|
+
|
|
17
|
+
// Label + value, value in the terminal's own foreground colour — chalk.gray is unreadable on a dark
|
|
18
|
+
// profile and these lines carry the information (Jimmy 2026-09-04).
|
|
19
|
+
const detail = (k, v) => console.log(` ${chalk.cyan(k.padEnd(14))}${v === null || v === undefined ? '-' : v}`)
|
|
20
|
+
|
|
21
|
+
// Minimal column-aligned printer: `columns` is [[header, rowFn], …].
|
|
22
|
+
function printTable(rows, columns) {
|
|
23
|
+
if (!rows.length) {
|
|
24
|
+
console.log(' (none)')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const cell = (v) => (v === null || v === undefined || v === '' ? '-' : String(v))
|
|
28
|
+
const body = rows.map((r) => columns.map(([, fn]) => cell(fn(r))))
|
|
29
|
+
const widths = columns.map(([h], i) => Math.max(h.length, ...body.map((b) => b[i].length)))
|
|
30
|
+
console.log(' ' + columns.map(([h], i) => chalk.bold(h.padEnd(widths[i]))).join(' '))
|
|
31
|
+
for (const b of body) console.log(' ' + b.map((v, i) => v.padEnd(widths[i])).join(' '))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { envOption, writeOption, detail, printTable }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const u = require('../users')
|
|
3
|
+
const remote = require('../remote')
|
|
4
|
+
const { envOption, writeOption, printTable, detail } = require('./target')
|
|
5
|
+
|
|
6
|
+
const date = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null)
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
command: 'user <command>',
|
|
10
|
+
describe: 'find, inspect and create users (local env by default)',
|
|
11
|
+
builder: (y) =>
|
|
12
|
+
y
|
|
13
|
+
.command({
|
|
14
|
+
command: 'search [query]',
|
|
15
|
+
describe: 'find users by email, name, personnummer or id (no query = the most recent users)',
|
|
16
|
+
builder: (y2) =>
|
|
17
|
+
envOption(y2)
|
|
18
|
+
.positional('query', { type: 'string', describe: 'substring to match' })
|
|
19
|
+
.option('role', { choices: u.USER_ROLES, describe: 'only users of this role' })
|
|
20
|
+
.option('limit', { type: 'number', default: 20, describe: 'max rows' }),
|
|
21
|
+
handler: async (argv) => {
|
|
22
|
+
const env = remote.resolveEnv(argv.env)
|
|
23
|
+
const rows = await u.searchUsers(env, { query: argv.query, role: argv.role, limit: argv.limit })
|
|
24
|
+
console.log(
|
|
25
|
+
chalk.bold(`\n${rows.length} user(s) in ${env.label}`) +
|
|
26
|
+
(argv.query ? ` matching "${argv.query}"` : '') +
|
|
27
|
+
'\n'
|
|
28
|
+
)
|
|
29
|
+
printTable(rows, [
|
|
30
|
+
['ID', (r) => r.id],
|
|
31
|
+
['EMAIL', (r) => r.email],
|
|
32
|
+
['NAME', (r) => `${r.firstName} ${r.lastName}`],
|
|
33
|
+
['TYPE', (r) => r.userType],
|
|
34
|
+
['CREATED', (r) => date(r.createdAt)],
|
|
35
|
+
['STATUS', (r) => (r.isInactivated ? `inactivated ${date(r.inactivatedAt)}` : 'active')],
|
|
36
|
+
])
|
|
37
|
+
console.log()
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
.command({
|
|
41
|
+
command: 'get <user>',
|
|
42
|
+
describe: 'show one user (by id or email) and the partners they belong to',
|
|
43
|
+
builder: (y2) => envOption(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
44
|
+
handler: async (argv) => {
|
|
45
|
+
const env = remote.resolveEnv(argv.env)
|
|
46
|
+
const user = await u.findUser(env, argv.user)
|
|
47
|
+
const partners = await u.partnersForUser(env, user.id)
|
|
48
|
+
console.log(chalk.bold(`\n${user.firstName} ${user.lastName}`) + ` (${env.label})\n`)
|
|
49
|
+
detail('id', user.id)
|
|
50
|
+
detail('email', `${user.email}${user.isEmailVerified ? ' (verified)' : chalk.yellow(' (unverified)')}`)
|
|
51
|
+
detail('user type', user.userType)
|
|
52
|
+
detail('personnummer', user.sePersonnummer || '-')
|
|
53
|
+
detail('language', user.language || '-')
|
|
54
|
+
detail('created', date(user.createdAt))
|
|
55
|
+
detail('status', user.isInactivated ? chalk.yellow(`inactivated ${date(user.inactivatedAt)}`) : 'active')
|
|
56
|
+
if (user.internal) detail('internal', 'yes')
|
|
57
|
+
console.log(chalk.bold(`\n partners (${partners.length})`))
|
|
58
|
+
printTable(partners, [
|
|
59
|
+
['PARTNER', (r) => r.name],
|
|
60
|
+
['LEVEL', (r) => r.level],
|
|
61
|
+
['STATUS', (r) => r.status],
|
|
62
|
+
['ID', (r) => r.id],
|
|
63
|
+
])
|
|
64
|
+
console.log()
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
.command({
|
|
68
|
+
command: 'create',
|
|
69
|
+
describe: 'create a user (password generated unless given). Dry-run by default.',
|
|
70
|
+
builder: (y2) =>
|
|
71
|
+
writeOption(envOption(y2))
|
|
72
|
+
.option('email', { type: 'string', demandOption: true, describe: 'email address (the login)' })
|
|
73
|
+
.option('password', { type: 'string', describe: 'password to set (default: generated, printed once)' })
|
|
74
|
+
.option('role', { choices: u.USER_ROLES, default: 'hi', describe: 'user role at dooer' })
|
|
75
|
+
.option('personnummer', { type: 'string', describe: 'Swedish personnummer, 10 or 12 digits' })
|
|
76
|
+
.option('first-name', { type: 'string', describe: 'default: derived from the email' })
|
|
77
|
+
.option('last-name', { type: 'string', describe: 'default: derived from the email' })
|
|
78
|
+
.option('partner', { type: 'string', describe: 'also add to this partner (id, name or domain)' })
|
|
79
|
+
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level in --partner' }),
|
|
80
|
+
handler: async (argv) => {
|
|
81
|
+
const env = remote.resolveEnv(argv.env)
|
|
82
|
+
const derived = u.namesFromEmail(argv.email)
|
|
83
|
+
const firstName = argv.firstName || derived.firstName
|
|
84
|
+
const lastName = argv.lastName || derived.lastName
|
|
85
|
+
const password = argv.password || u.generatePassword()
|
|
86
|
+
const generated = !argv.password
|
|
87
|
+
const personnummer = argv.personnummer ? u.normalizePersonnummer(argv.personnummer) : null
|
|
88
|
+
|
|
89
|
+
// Resolve the partner BEFORE creating anything, so a typo fails without leaving a half-made user.
|
|
90
|
+
const partner = argv.partner ? await u.findPartner(env, argv.partner) : null
|
|
91
|
+
if (partner && argv.role !== 'hi') {
|
|
92
|
+
throw new Error(`--partner needs a "hi" user; role "${argv.role}" cannot belong to a partner`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let created = null
|
|
96
|
+
if (argv.execute) {
|
|
97
|
+
created = await u.createUser(env, {
|
|
98
|
+
email: argv.email,
|
|
99
|
+
password,
|
|
100
|
+
role: argv.role,
|
|
101
|
+
personnummer,
|
|
102
|
+
firstName,
|
|
103
|
+
lastName,
|
|
104
|
+
})
|
|
105
|
+
if (partner) await u.assignPartner(env, { userId: created.id, partnerId: partner.id, level: argv.level })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(
|
|
109
|
+
(argv.execute ? chalk.green('\ncreated user') : chalk.yellow('\nDRY RUN — nothing written')) +
|
|
110
|
+
` (${env.label})\n`
|
|
111
|
+
)
|
|
112
|
+
if (created) detail('id', created.id)
|
|
113
|
+
detail('email', argv.email)
|
|
114
|
+
detail('name', `${firstName} ${lastName}`)
|
|
115
|
+
detail('user type', argv.role)
|
|
116
|
+
if (personnummer) detail('personnummer', personnummer)
|
|
117
|
+
detail('password', chalk.bold(password) + (generated ? ' (generated)' : ''))
|
|
118
|
+
if (partner) detail('partner', `${partner.name} — level ${argv.level}`)
|
|
119
|
+
if (generated && argv.execute) {
|
|
120
|
+
console.log('\n The service stores only a hash — this is the only time the password is shown.')
|
|
121
|
+
}
|
|
122
|
+
if (!argv.execute) console.log('\n Re-run with --execute to create it.')
|
|
123
|
+
console.log()
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
.command({
|
|
127
|
+
command: 'partners <user>',
|
|
128
|
+
describe: 'list the partners a user belongs to, and at what level',
|
|
129
|
+
builder: (y2) => envOption(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
130
|
+
handler: async (argv) => {
|
|
131
|
+
const env = remote.resolveEnv(argv.env)
|
|
132
|
+
const user = await u.findUser(env, argv.user)
|
|
133
|
+
const partners = await u.partnersForUser(env, user.id)
|
|
134
|
+
console.log(chalk.bold(`\n${user.email} belongs to ${partners.length} partner(s)`) + ` (${env.label})\n`)
|
|
135
|
+
printTable(partners, [
|
|
136
|
+
['PARTNER', (r) => r.name],
|
|
137
|
+
['DOMAIN', (r) => r.domain],
|
|
138
|
+
['LEVEL', (r) => r.level],
|
|
139
|
+
['STATUS', (r) => r.status],
|
|
140
|
+
['ID', (r) => r.id],
|
|
141
|
+
])
|
|
142
|
+
console.log()
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
.demandCommand(1, 'Use: user search | user get | user create | user partners')
|
|
146
|
+
.strict(),
|
|
147
|
+
handler: () => {},
|
|
148
|
+
}
|