@dooer/dooer-test-env 1.14.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 CHANGED
@@ -6,25 +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 yargs(hideBin(process.argv))
10
- .scriptName('dooer-test-env')
11
- .usage('$0 <group> <command> [options]')
12
- .version(pkg.version)
13
- .command(require('./command/setup')) // prerequisites + registry login + compose generation
14
- .command(require('./command/env')) // up / down / start / stop / status
15
- .command(require('./command/service')) // service start|stop|restart|deploy|local|unlocal|version
16
- .command(require('./command/db')) // db build|pull|snapshot|rollback
17
- .command(require('./command/customer')) // customer copy|purge
18
- .command(require('./command/user')) // user search|get|create|partners
19
- .command(require('./command/partner')) // partner list|users|assign|unassign
20
- .command(require('./command/shred')) // shred (localhost only)
21
- .command(require('./command/bankid')) // bankid pull|status|clear (staging certs → keychain)
22
- .command(require('./command/validation')) // validation on|off|status (output-schema checks)
23
- .command(require('./command/logs')) // search logs across all local services (transactionId, errors, …)
24
- .command(require('./command/measure')) // resource-usage report for the running env
25
- .demandCommand(1, 'Pick a command group. Try --help.')
26
- .strict()
27
- .help()
28
- .wrap(Math.min(120, yargs().terminalWidth()))
29
- .parseAsync()
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
+ )
30
45
  }
@@ -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 } = require('./target')
4
5
 
5
6
  module.exports = {
6
7
  command: 'partner <command>',
@@ -10,17 +11,15 @@ module.exports = {
10
11
  .command({
11
12
  command: 'list',
12
13
  describe: 'list every partner in the environment',
13
- builder: targetOptions,
14
+ builder: envOption,
14
15
  handler: async (argv) => {
15
- const ns = u.targetOf(argv)
16
- const rows = await u.withDb(ns, (db) => u.listPartners(db))
17
- console.log(chalk.bold(`\n${rows.length} partner(s) in ${label(ns)}\n`))
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`))
18
19
  printTable(rows, [
19
20
  ['NAME', (r) => r.name],
20
21
  ['DOMAIN', (r) => r.domain],
21
- ['USERS', (r) => r.users],
22
22
  ['STATUS', (r) => r.status],
23
- ['DEFAULT', (r) => (r.isDefaultPartner ? 'yes' : '')],
24
23
  ['ID', (r) => r.id],
25
24
  ])
26
25
  console.log()
@@ -30,29 +29,19 @@ module.exports = {
30
29
  command: 'users <partner>',
31
30
  describe: 'list the users of one partner and their level',
32
31
  builder: (y2) =>
33
- targetOptions(y2).positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
32
+ envOption(y2).positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
34
33
  handler: async (argv) => {
35
- const ns = u.targetOf(argv)
36
- const { partner, rows } = await u.withDb(ns, async (db) => {
37
- const partner = await u.findPartner(db, argv.partner)
38
- const { rows } = await db.query(
39
- `SELECT us.users_pk AS id, us.email, us.first_name, us.last_name,
40
- pu.role AS level, pu.status, pu."defaultRepresentative"
41
- FROM "service_accounts"."partnerUser" pu
42
- JOIN "service_accounts".users us ON us.users_pk = pu."userId"
43
- WHERE pu."partnerId" = $1 ORDER BY us.email`,
44
- [partner.id]
45
- )
46
- return { partner, rows }
47
- })
48
- console.log(chalk.bold(`\n${partner.name} — ${rows.length} user(s)`) + chalk.gray(` (${label(ns)})\n`))
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`)
49
38
  printTable(rows, [
50
- ['EMAIL', (r) => r.email],
51
- ['NAME', (r) => `${r.first_name} ${r.last_name}`],
52
- ['LEVEL', (r) => r.level],
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],
53
42
  ['STATUS', (r) => r.status],
54
43
  ['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
55
- ['ID', (r) => r.id],
44
+ ['ID', (r) => r.userId],
56
45
  ])
57
46
  console.log()
58
47
  },
@@ -61,31 +50,24 @@ module.exports = {
61
50
  command: 'assign <user> <partner>',
62
51
  describe: 'add a user to a partner (or change their level). Dry-run by default.',
63
52
  builder: (y2) =>
64
- writeOptions(y2)
53
+ writeOption(envOption(y2))
65
54
  .positional('user', { type: 'string', describe: 'user id or email' })
66
55
  .positional('partner', { type: 'string', describe: 'partner id, name or domain' })
67
56
  .option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level within the partner' }),
68
57
  handler: async (argv) => {
69
- const ns = u.targetOf(argv)
70
- u.guardWrite(ns, argv)
71
- const { user, partner, res } = await u.withDb(ns, async (db) => {
72
- const user = await u.findUser(db, argv.user)
73
- const partner = await u.findPartner(db, argv.partner)
74
- const res = await u.assignPartner(db, { user, partner, level: argv.level, execute: argv.execute })
75
- return { user, partner, res }
76
- })
77
- const what =
78
- res.previous && res.previous !== argv.level
79
- ? `level ${res.previous} → ${argv.level}`
80
- : res.previous
81
- ? `already a ${argv.level} (no change)`
82
- : `added as ${argv.level}`
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 })
83
66
  console.log(
84
- (argv.execute ? chalk.green('\n' + what) : chalk.yellow(`\nDRY RUN — would be ${what}`)) +
85
- `: ${user.email} @ ${partner.name}` +
86
- chalk.gray(` (${label(ns)})`)
67
+ (argv.execute ? chalk.green(`\nset ${argv.level}`) : chalk.yellow(`\nDRY RUN — would set ${argv.level}`)) +
68
+ `: ${user.email} @ ${partner.name} (${env.label})`
87
69
  )
88
- if (!argv.execute) console.log(chalk.gray(' Re-run with --execute to apply.'))
70
+ if (!argv.execute) console.log(' Re-run with --execute to apply.')
89
71
  console.log()
90
72
  },
91
73
  })
@@ -93,24 +75,19 @@ module.exports = {
93
75
  command: 'unassign <user> <partner>',
94
76
  describe: 'remove a user from a partner. Dry-run by default.',
95
77
  builder: (y2) =>
96
- writeOptions(y2)
78
+ writeOption(envOption(y2))
97
79
  .positional('user', { type: 'string', describe: 'user id or email' })
98
80
  .positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
99
81
  handler: async (argv) => {
100
- const ns = u.targetOf(argv)
101
- u.guardWrite(ns, argv)
102
- const { user, partner, res } = await u.withDb(ns, async (db) => {
103
- const user = await u.findUser(db, argv.user)
104
- const partner = await u.findPartner(db, argv.partner)
105
- const res = await u.unassignPartner(db, { user, partner, execute: argv.execute })
106
- return { user, partner, res }
107
- })
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 })
108
86
  console.log(
109
87
  (argv.execute ? chalk.green('\nremoved') : chalk.yellow('\nDRY RUN — would remove')) +
110
- ` ${user.email} (${res.level}) from ${partner.name}` +
111
- chalk.gray(` (${label(ns)})`)
88
+ ` ${user.email} from ${partner.name} (${env.label})`
112
89
  )
113
- if (!argv.execute) console.log(chalk.gray(' Re-run with --execute to apply.'))
90
+ if (!argv.execute) console.log(' Re-run with --execute to apply.')
114
91
  console.log()
115
92
  },
116
93
  })
@@ -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`)
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
+ }
@@ -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 }