@dooer/dooer-test-env 1.14.0 → 1.16.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.
@@ -0,0 +1,292 @@
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`)
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: 'token',
209
+ describe: 'print the stored token for an environment — bare, for scripts',
210
+ builder: envOption,
211
+ handler: (argv) => {
212
+ const env = remote.resolveEnv(argv.env)
213
+ // Deliberately the ONLY thing on stdout: no colour, no label, no trailing prose, so
214
+ // `$(… token)` and `| pbcopy` both do the obvious thing. Failures go to stderr via cli.js.
215
+ process.stdout.write(remote.tokenFor(env.name) + '\n')
216
+ },
217
+ })
218
+ .command({
219
+ command: 'status',
220
+ describe: 'who you are logged in as, and until when',
221
+ builder: (y2) => envOption(y2).option('all', { type: 'boolean', describe: 'report every environment' }),
222
+ handler: async (argv) => {
223
+ const names = argv.all ? remote.envNames() : [remote.resolveEnv(argv.env).name]
224
+ console.log()
225
+ for (const name of names) {
226
+ const env = remote.resolveEnv(name)
227
+ const token = remote.tokenGet(name)
228
+ const head = `${chalk.bold(env.label.padEnd(22))}`
229
+ if (!token) {
230
+ // The local stack needs no login — it is signed with the shared dev keypair this CLI holds.
231
+ const note =
232
+ name === 'local' ? plain('no session — uses a minted service token') : chalk.yellow('not logged in')
233
+ console.log(`${head}${note}`)
234
+ continue
235
+ }
236
+ const exp = remote.expiry(token)
237
+ if (exp && exp.expired) {
238
+ console.log(`${head}${chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)}`)
239
+ continue
240
+ }
241
+ let me = null
242
+ try {
243
+ me = await remote.currentUser(env, token)
244
+ } catch (e) {
245
+ console.log(`${head}${chalk.red(`session rejected — ${e.message}`)}`)
246
+ continue
247
+ }
248
+ console.log(
249
+ `${head}${chalk.green(me ? `${me.firstName} ${me.lastName} <${me.email}>` : 'valid session')}` +
250
+ plain(exp ? ` until ${exp.at.toISOString()}` : '')
251
+ )
252
+ // The account type decides what this session may do, so it belongs in the whoami line.
253
+ if (me) console.log(`${''.padEnd(22)}${me.id} ${chalk.cyan(me.userType || 'unknown type')}`)
254
+ }
255
+ console.log()
256
+ },
257
+ })
258
+ .command({
259
+ command: 'logout',
260
+ describe: 'forget the stored session for an environment',
261
+ builder: (y2) => envOption(y2),
262
+ handler: (argv) => {
263
+ const env = remote.resolveEnv(argv.env)
264
+ const had = remote.tokenDelete(env.name)
265
+ console.log(had ? chalk.green(`\nforgot the ${env.label} session\n`) : plain(`\nno ${env.label} session\n`))
266
+ },
267
+ })
268
+ .command({
269
+ command: 'list',
270
+ describe: 'the environments this CLI knows and where they point',
271
+ handler: () => {
272
+ console.log()
273
+ for (const name of remote.envNames()) {
274
+ const env = remote.resolveEnv(name)
275
+ const token = remote.tokenGet(name)
276
+ const exp = token ? remote.expiry(token) : null
277
+ const state = !token
278
+ ? name === 'local'
279
+ ? plain('service token')
280
+ : chalk.yellow('not logged in')
281
+ : exp && exp.expired
282
+ ? chalk.yellow('expired')
283
+ : chalk.green('logged in')
284
+ console.log(` ${name.padEnd(12)}${env.url.padEnd(36)}${state}`)
285
+ }
286
+ console.log()
287
+ },
288
+ })
289
+ .demandCommand(1, 'Use: remote-environment login | status | logout | list')
290
+ .strict(),
291
+ handler: () => {},
292
+ }
@@ -1,24 +1,27 @@
1
1
  const chalk = require('chalk')
2
+ const remote = require('../remote')
2
3
 
3
- // Shared bits for the environment-addressed commands (user / partner). The LOCAL env is the default —
4
- // these are day-to-day local-dev commands and a k8s namespace is the deliberate opt-in.
5
- const targetOptions = (y) =>
6
- y
7
- .option('target-local', { type: 'boolean', describe: 'act on the LOCAL env (default)' })
8
- .option('target-namespace', { type: 'string', describe: 'act on a k8s namespace instead, e.g. dooer-staging' })
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
+ })
9
13
 
10
- const writeOptions = (y) =>
11
- targetOptions(y)
12
- .option('confirm-production', { type: 'boolean', describe: 'REQUIRED to write to dooer-production' })
13
- .option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
14
+ const writeOption = (y) =>
15
+ y.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
14
16
 
15
- const label = (ns) => (ns === 'local' ? 'local env' : `namespace ${ns}`)
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}`)
16
20
 
17
- // Minimal column-aligned printer: `columns` is [[header, rowFn], …]. Values are stringified, null shown
18
- // as a dim dash so an empty column still lines up.
21
+ // Minimal column-aligned printer: `columns` is [[header, rowFn], …].
19
22
  function printTable(rows, columns) {
20
23
  if (!rows.length) {
21
- console.log(chalk.gray(' (none)'))
24
+ console.log(' (none)')
22
25
  return
23
26
  }
24
27
  const cell = (v) => (v === null || v === undefined || v === '' ? '-' : String(v))
@@ -28,4 +31,4 @@ function printTable(rows, columns) {
28
31
  for (const b of body) console.log(' ' + b.map((v, i) => v.padEnd(widths[i])).join(' '))
29
32
  }
30
33
 
31
- module.exports = { targetOptions, writeOptions, label, printTable }
34
+ module.exports = { envOption, writeOption, detail, printTable }
@@ -1,6 +1,7 @@
1
1
  const chalk = require('chalk')
2
2
  const u = require('../users')
3
- const { targetOptions, writeOptions, label, printTable } = require('./target')
3
+ const remote = require('../remote')
4
+ const { envOption, writeOption, printTable, detail } = require('./target')
4
5
 
5
6
  const date = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null)
6
7
 
@@ -13,27 +14,25 @@ module.exports = {
13
14
  command: 'search [query]',
14
15
  describe: 'find users by email, name, personnummer or id (no query = the most recent users)',
15
16
  builder: (y2) =>
16
- targetOptions(y2)
17
+ envOption(y2)
17
18
  .positional('query', { type: 'string', describe: 'substring to match' })
18
19
  .option('role', { choices: u.USER_ROLES, describe: 'only users of this role' })
19
20
  .option('limit', { type: 'number', default: 20, describe: 'max rows' }),
20
21
  handler: async (argv) => {
21
- const ns = u.targetOf(argv)
22
- const rows = await u.withDb(ns, (db) =>
23
- u.searchUsers(db, { query: argv.query, role: argv.role, limit: argv.limit })
24
- )
22
+ const env = remote.resolveEnv(argv.env)
23
+ const rows = await u.searchUsers(env, { query: argv.query, role: argv.role, limit: argv.limit })
25
24
  console.log(
26
- chalk.bold(`\n${rows.length} user(s) in ${label(ns)}`) +
27
- (argv.query ? chalk.gray(` matching "${argv.query}"`) : '') +
25
+ chalk.bold(`\n${rows.length} user(s) in ${env.label}`) +
26
+ (argv.query ? ` matching "${argv.query}"` : '') +
28
27
  '\n'
29
28
  )
30
29
  printTable(rows, [
31
30
  ['ID', (r) => r.id],
32
31
  ['EMAIL', (r) => r.email],
33
- ['NAME', (r) => `${r.first_name} ${r.last_name}`],
34
- ['ROLE', (r) => r.role],
35
- ['CREATED', (r) => date(r.inserted_at)],
36
- ['STATUS', (r) => (r.inactivated_at ? `inactivated ${date(r.inactivated_at)}` : 'active')],
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')],
37
36
  ])
38
37
  console.log()
39
38
  },
@@ -41,33 +40,25 @@ module.exports = {
41
40
  .command({
42
41
  command: 'get <user>',
43
42
  describe: 'show one user (by id or email) and the partners they belong to',
44
- builder: (y2) => targetOptions(y2).positional('user', { type: 'string', describe: 'user id or email' }),
43
+ builder: (y2) => envOption(y2).positional('user', { type: 'string', describe: 'user id or email' }),
45
44
  handler: async (argv) => {
46
- const ns = u.targetOf(argv)
47
- const { user, partners } = await u.withDb(ns, async (db) => {
48
- const user = await u.findUser(db, argv.user)
49
- return { user, partners: await u.partnersForUser(db, user.id) }
50
- })
51
- console.log(chalk.bold(`\n${user.first_name} ${user.last_name}`) + chalk.gray(` (${label(ns)})\n`))
52
- const line = (k, v) => console.log(` ${k.padEnd(14)}${v === null || v === undefined ? '-' : v}`)
53
- line('id', user.id)
54
- line(
55
- 'email',
56
- `${user.email}${user.email_verified ? chalk.gray(' (verified)') : chalk.yellow(' (unverified)')}`
57
- )
58
- line('role', user.role)
59
- line('personnummer', user.personnummer)
60
- line('password', user.has_password ? 'set' : chalk.yellow('none — cannot log in with a password'))
61
- line('language', user.language)
62
- line('created', date(user.inserted_at))
63
- line('status', user.inactivated_at ? chalk.yellow(`inactivated ${date(user.inactivated_at)}`) : 'active')
64
- if (user.internal) line('internal', 'yes')
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')
65
57
  console.log(chalk.bold(`\n partners (${partners.length})`))
66
58
  printTable(partners, [
67
59
  ['PARTNER', (r) => r.name],
68
60
  ['LEVEL', (r) => r.level],
69
61
  ['STATUS', (r) => r.status],
70
- ['LAST LOGIN', (r) => date(r.lastLoginAt)],
71
62
  ['ID', (r) => r.id],
72
63
  ])
73
64
  console.log()
@@ -77,7 +68,7 @@ module.exports = {
77
68
  command: 'create',
78
69
  describe: 'create a user (password generated unless given). Dry-run by default.',
79
70
  builder: (y2) =>
80
- writeOptions(y2)
71
+ writeOption(envOption(y2))
81
72
  .option('email', { type: 'string', demandOption: true, describe: 'email address (the login)' })
82
73
  .option('password', { type: 'string', describe: 'password to set (default: generated, printed once)' })
83
74
  .option('role', { choices: u.USER_ROLES, default: 'hi', describe: 'user role at dooer' })
@@ -87,8 +78,7 @@ module.exports = {
87
78
  .option('partner', { type: 'string', describe: 'also add to this partner (id, name or domain)' })
88
79
  .option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level in --partner' }),
89
80
  handler: async (argv) => {
90
- const ns = u.targetOf(argv)
91
- u.guardWrite(ns, argv)
81
+ const env = remote.resolveEnv(argv.env)
92
82
  const derived = u.namesFromEmail(argv.email)
93
83
  const firstName = argv.firstName || derived.firstName
94
84
  const lastName = argv.lastName || derived.lastName
@@ -96,71 +86,57 @@ module.exports = {
96
86
  const generated = !argv.password
97
87
  const personnummer = argv.personnummer ? u.normalizePersonnummer(argv.personnummer) : null
98
88
 
99
- const res = await u.withDb(ns, async (db) => {
100
- // Resolve the partner BEFORE creating anything, so a typo fails without leaving a half-made user.
101
- const partner = argv.partner ? await u.findPartner(db, argv.partner) : null
102
- if (partner && argv.role !== 'hi') {
103
- throw new Error(`--partner needs a "hi" user; role "${argv.role}" cannot belong to a partner`)
104
- }
105
- const created = await u.createUser(db, {
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, {
106
98
  email: argv.email,
107
99
  password,
108
100
  role: argv.role,
109
101
  personnummer,
110
102
  firstName,
111
103
  lastName,
112
- execute: argv.execute,
113
104
  })
114
- // Only on --execute: a dry-run has no user id to attach the membership to, and the partner
115
- // itself (plus the hi-only rule) has already been validated above.
116
- if (partner && argv.execute) {
117
- await u.assignPartner(db, {
118
- user: { id: created.id, email: argv.email, role: argv.role },
119
- partner,
120
- level: argv.level,
121
- execute: argv.execute,
122
- })
123
- }
124
- return { created, partner }
125
- })
105
+ if (partner) await u.assignPartner(env, { userId: created.id, partnerId: partner.id, level: argv.level })
106
+ }
126
107
 
127
108
  console.log(
128
109
  (argv.execute ? chalk.green('\ncreated user') : chalk.yellow('\nDRY RUN — nothing written')) +
129
- chalk.gray(` (${label(ns)})\n`)
110
+ ` (${env.label})\n`
130
111
  )
131
- const line = (k, v) => console.log(` ${k.padEnd(14)}${v}`)
132
- if (argv.execute) line('id', res.created.id)
133
- line('email', argv.email)
134
- line('name', `${firstName} ${lastName}`)
135
- line('role', argv.role)
136
- if (personnummer) line('personnummer', personnummer)
137
- line('password', chalk.bold(password) + (generated ? chalk.gray(' (generated)') : ''))
138
- if (res.partner) line('partner', `${res.partner.name} — level ${argv.level}`)
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}`)
139
119
  if (generated && argv.execute) {
140
- console.log(chalk.gray('\n The password is stored as a bcrypt hash — this is the only time it is shown.'))
120
+ console.log('\n The service stores only a hash — this is the only time the password is shown.')
141
121
  }
142
- if (!argv.execute) console.log(chalk.gray('\n Re-run with --execute to create it.'))
122
+ if (!argv.execute) console.log('\n Re-run with --execute to create it.')
143
123
  console.log()
144
124
  },
145
125
  })
146
126
  .command({
147
127
  command: 'partners <user>',
148
128
  describe: 'list the partners a user belongs to, and at what level',
149
- builder: (y2) => targetOptions(y2).positional('user', { type: 'string', describe: 'user id or email' }),
129
+ builder: (y2) => envOption(y2).positional('user', { type: 'string', describe: 'user id or email' }),
150
130
  handler: async (argv) => {
151
- const ns = u.targetOf(argv)
152
- const { user, partners } = await u.withDb(ns, async (db) => {
153
- const user = await u.findUser(db, argv.user)
154
- return { user, partners: await u.partnersForUser(db, user.id) }
155
- })
156
- console.log(
157
- chalk.bold(`\n${user.email} belongs to ${partners.length} partner(s)`) + chalk.gray(` (${label(ns)})\n`)
158
- )
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`)
159
135
  printTable(partners, [
160
136
  ['PARTNER', (r) => r.name],
137
+ ['DOMAIN', (r) => r.domain],
161
138
  ['LEVEL', (r) => r.level],
162
139
  ['STATUS', (r) => r.status],
163
- ['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
164
140
  ['ID', (r) => r.id],
165
141
  ])
166
142
  console.log()