bod-cli 0.10.8 → 0.10.9

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.
@@ -138,13 +138,42 @@ bod host ./assets --app my-api # persistent app assets
138
138
  Manage environment variables.
139
139
 
140
140
  ```bash
141
- bod env list my-api
141
+ bod env list my-api # values MASKED (key, masked value, length)
142
+ bod env list my-api --reveal # full plaintext, as KEY=VALUE lines
142
143
  bod env set my-api DATABASE_URL=postgres://...
143
144
  bod env set my-api -f .env # bulk set from .env file
144
145
  bod env unset my-api OLD_VAR
145
146
  bod env pull my-api # write the resolved env to ./.env (0600)
146
147
  ```
147
148
 
149
+ **`bod env list <app>` masks values by default.** It is the only scope that resolves
150
+ plaintext secrets, and it used to print them straight to the terminal (and into any
151
+ scrollback, log or pipe). The mask shows a short tail plus a length, enough to answer
152
+ "is this the same key I have locally?" without exposing the value:
153
+
154
+ - **Under 20 codepoints → nothing is revealed.** A short value is usually low-entropy
155
+ (`production`, `true`, a PIN, a short enum) and a tail plus the length gives it away
156
+ outright — `••••••on` / len 10 *is* `production`. Real secrets are long, so this
157
+ costs the useful case nothing.
158
+ - **20 or more → `floor(len/5)` characters, capped at 4** — the familiar "…last four".
159
+ - An **empty** value renders as a dim `(empty)`, not as dots: `••••••` with `len 0`
160
+ would be indistinguishable from a masked secret.
161
+ - Everything is counted in **Unicode codepoints**, so an emoji is never sliced in half.
162
+ Characters that could corrupt the table are stripped from the tail: C0, DEL, C1
163
+ (U+0080–U+009F — a bare U+009B is a CSI that misaligns every column), U+2028/U+2029,
164
+ and the bidi controls (U+200E/U+200F, U+202A–U+202E, U+2066–U+2069) — U+202E alone
165
+ visually reverses the rest of the row.
166
+
167
+ The `len` column is that same codepoint count, and is deliberately **exact** — masking
168
+ cannot defeat low entropy anyway (the 20-codepoint threshold is what does), and an exact
169
+ length is what makes the listing useful for **comparing** two values (spotting a
170
+ truncated or whitespace-padded paste). It is not a byte count and not a grapheme count, so a decomposed-accent or flag-emoji string reports more than
171
+ it visibly renders. Masking is **not** TTY-conditional: piping does not switch
172
+ plaintext back on. For scripts use `--reveal`
173
+ (byte-identical to the old `KEY=VALUE` output) or `bod env pull … --stdout`.
174
+ `--reveal` is rejected with `--global/--group/--subs`, which only ever receive
175
+ server-side masked values.
176
+
148
177
  **`bod env pull` never clobbers an existing file.** A `.env` is hand-maintained and
149
178
  usually holds local-only credentials the server has never seen — replacing it can
150
179
  destroy the only copy. If the output file already exists, `pull` **refuses**, prints a
package/CLAUDE.md CHANGED
@@ -33,7 +33,7 @@ src/
33
33
  ├── rollback.ts # bod rollback [app]
34
34
  ├── apps.ts # bod apps list|status
35
35
  ├── logs.ts # bod logs <app> [-f]
36
- ├── env.ts # bod env list|set|unset|pull; scoped by <app>/--global/--group (+ --env); set supports -f .env; pull NEVER clobbers (--merge/--force/--stdout)
36
+ ├── env.ts # bod env list|set|unset|pull; scoped by <app>/--global/--group (+ --env); list MASKS values (--reveal for plaintext; never TTY-conditional); set supports -f .env; pull NEVER clobbers (--merge/--force/--stdout)
37
37
  ├── open.ts # bod open <app>
38
38
  ├── add.ts # bod add <pkg>
39
39
  └── remove.ts # bod remove <pkg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -134,11 +134,60 @@ function diffEnv(existing: Record<string, string>, incoming: Record<string, stri
134
134
  return { added, changed, removed }
135
135
  }
136
136
 
137
+ /** Characters that must never reach a table cell, in one place.
138
+ * C0 + DEL + C1 (U+0080-U+009F: a bare U+009B is a CSI, which makes cli-table3
139
+ * miscount the cell width and misalign every column after it), the Unicode line and
140
+ * paragraph separators U+2028/U+2029, and the bidi controls (U+200E/U+200F,
141
+ * U+202A-U+202E, U+2066-U+2069) — U+202E alone visually REVERSES the rest of the row,
142
+ * which is how a value lies about which key it belongs to. */
143
+ const UNSAFE_CELL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
144
+
145
+ /** Shown instead of dots for a value that is the empty string: `••••••` with `len 0`
146
+ * is indistinguishable from a masked secret at a glance, and "this var is set to
147
+ * nothing" is a different, actionable fact. */
148
+ export const EMPTY_MARKER = '(empty)'
149
+
150
+ /** Mask a resolved value for on-screen display.
151
+ *
152
+ * The tail is what makes a mask USEFUL — it is how you answer "is the deployed key
153
+ * the same one I have locally?" without printing the secret. But a tail only stays
154
+ * safe when the value is long enough that a few characters cannot reconstruct it:
155
+ * len < 20 → 0 revealed. Anything shorter is likely low-entropy (`production`,
156
+ * `true`, a PIN, a short enum) and a tail plus the length column
157
+ * de-anonymises it outright — `••••••on` / len 10 IS `production`.
158
+ * Real secrets are long, so this costs the useful case nothing.
159
+ * len >= 20 → floor(len/5), capped at 4 — the familiar "…last four" affordance.
160
+ * Everything is measured in CODEPOINTS, not UTF-16 code units, so an odd budget
161
+ * cannot slice an astral character (emoji, some CJK) in half and emit a lone
162
+ * surrogate. The `len` column reports the same unit.
163
+ * UNSAFE_CELL_CHARS are dropped from the tail so a value cannot break, reverse or
164
+ * misalign the table it is printed in.
165
+ *
166
+ * NOTE on `len`: it is deliberately still exact. Masking cannot defeat low entropy
167
+ * anyway — the length threshold above is the defence — and an exact length is what
168
+ * makes the listing useful for spotting a truncated or whitespace-padded paste. It
169
+ * counts codepoints of the resolved value: NOT a byte count and NOT a grapheme count
170
+ * (a combining-accent/NFD or flag-emoji string reports more than it renders).
171
+ * Compare it between two values; don't read it as "characters as a human counts". */
172
+ export function maskValue(value: string): string {
173
+ if (value === '') return EMPTY_MARKER
174
+ const chars = [...value]
175
+ const reveal = chars.length < 20 ? 0 : Math.min(4, Math.floor(chars.length / 5))
176
+ const tail = reveal ? chars.slice(-reveal).join('').replace(UNSAFE_CELL_CHARS, '') : ''
177
+ return '••••••' + tail
178
+ }
179
+
180
+ /** Codepoint length — the unit the `len` column reports, matching maskValue's budget. */
181
+ export function displayLength(value: string): number {
182
+ return [...value].length
183
+ }
184
+
137
185
  const listCmd = defineCommand({
138
- meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group/--subs: variables in that scope (masked).' },
186
+ meta: { name: 'list', description: 'List env vars (values MASKED by default; --reveal prints them in full). --global/--group/--subs: variables in that scope.' },
139
187
  args: {
140
188
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
141
189
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
190
+ reveal: { type: 'boolean', description: 'Print full plaintext values (per-app scope) as KEY=VALUE' },
142
191
  global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
143
192
  group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
144
193
  subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
@@ -150,6 +199,12 @@ const listCmd = defineCommand({
150
199
 
151
200
  const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
152
201
  assertScopeFlags(args.app, !!args.global, args.group, subs, args.env)
202
+ // The other scopes only ever receive server-side `masked` strings — there is no
203
+ // plaintext to reveal there. Say so instead of silently ignoring the flag.
204
+ if (args.reveal && (args.global || args.group || isSubsMode(subs))) {
205
+ console.error(chalk.red('--reveal only applies to the per-app scope; --global/--group/--subs return masked values from the server.'))
206
+ process.exit(1)
207
+ }
153
208
 
154
209
  if (isSubsMode(subs)) {
155
210
  const scopeApp = await buildSubsScopeApp(client, subs)
@@ -186,7 +241,7 @@ const listCmd = defineCommand({
186
241
  return
187
242
  }
188
243
 
189
- // Per-app: show fully resolved values for the chosen environment.
244
+ // Per-app: the only scope that can resolve PLAINTEXT secrets. Mask unless asked.
190
245
  const appId = await resolveAppId(client, resolveAppName(args.app))
191
246
  const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
192
247
  const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
@@ -195,7 +250,26 @@ const listCmd = defineCommand({
195
250
  console.log(chalk.dim('No environment variables set.'))
196
251
  return
197
252
  }
198
- for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
253
+
254
+ // Machine output: DELIBERATELY not TTY-conditional. Piping into a file or a log
255
+ // is exactly the case where leaking plaintext hurts most, so a pipe must not
256
+ // silently switch the output back to secrets — and a script must see what the
257
+ // human saw. Scripting keeps two explicit, unchanged paths: `--reveal` (emits the
258
+ // pre-masking KEY=VALUE format byte-for-byte) and `bod env pull --stdout`.
259
+ if (args.reveal) {
260
+ for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
261
+ return
262
+ }
263
+
264
+ printTable(
265
+ Object.entries(values).map(([key, v]) => {
266
+ const cell = maskValue(v)
267
+ return { key, value: cell === EMPTY_MARKER ? chalk.dim(cell) : cell, len: displayLength(v) }
268
+ }),
269
+ ['key', 'value', 'len'],
270
+ )
271
+ // Hint on stderr so stdout stays a clean, parseable table.
272
+ console.error(chalk.dim(' values masked — `bod env list … --reveal` to print them, or `bod env pull … --stdout`'))
199
273
  },
200
274
  })
201
275
 
@@ -0,0 +1,214 @@
1
+ // `bod env list <app>` is the ONLY scope that can resolve plaintext secrets. It must
2
+ // mask them by default — including when piped — and reveal only on explicit request.
3
+ // Verified at the REAL CLI surface (full citty parse) against a mock agent.
4
+ import { test, expect, beforeAll, afterAll } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { maskValue, displayLength } from '../src/commands/env'
9
+
10
+ const CLI = join(import.meta.dir, '..', 'src', 'cli.ts')
11
+ const APP_ID = 'e1dba964-b944-4dd0-b971-fe72ee493bf6'
12
+ const APP_NAME = 'blank'
13
+
14
+ const REMOTE: Record<string, string> = {
15
+ STRIPE_KEY: 'sk_live_51NxAbCdEfGhIjKlMnOpQrStU', // 33 cp -> 4 revealed
16
+ BOUND_19: 'abcdefghijklmnopqrs', // 19 cp -> 0 revealed (last length hidden)
17
+ BOUND_20: 'abcdefghijklmnopqrst', // 20 cp -> 4 revealed (first length shown)
18
+ LOW_ENTROPY: 'production', // 10 cp -> 0: the tail used to give it away
19
+ SHORT_PIN: '4821',
20
+ EMOJI_VAL: '\u{1F600}'.repeat(20), // astral: budget must not split a pair
21
+ EMPTY_VAL: '',
22
+ // One fixture per control-char class, each 20 codepoints so a tail IS budgeted and
23
+ // the strip is actually exercised. Tail = the last 4 codepoints in every case.
24
+ C0_VAL: 'abcdefghijklmnop\r\n\tx', // C0 + DEL family
25
+ DEL_VAL: 'abcdefghijklmnop\u007f\u007f\u007fx',
26
+ C1_VAL: 'abcdefghijklmnop\u009b\u0085\u0080x', // U+009B = CSI (breaks table width), U+0085 = NEL
27
+ SEP_VAL: 'abcdefghijklmnop\u2028\u2029\u2028x', // line / paragraph separators
28
+ BIDI_VAL: 'abcdefghijklmnop\u202e\u200f\u2066x', // RTL override + mark + isolate
29
+ }
30
+
31
+ // What the server returns for --global: it masks server-side, so `masked` is the
32
+ // authoritative display string and the CLI must pass it through untouched.
33
+ const GLOBAL_VARS = [
34
+ { key: 'SHARED_DB_URL', entries: [{ scope: {}, masked: 'post***rres', updatedAt: 0 }] },
35
+ { key: 'TEAM_TOKEN', entries: [{ scope: { group: 'infra' }, masked: 'ghp_***', updatedAt: 0 }] },
36
+ ]
37
+
38
+ let server: ReturnType<typeof Bun.serve>
39
+ let home: string
40
+ let cwd: string
41
+
42
+ beforeAll(() => {
43
+ server = Bun.serve({
44
+ port: 0,
45
+ fetch(req) {
46
+ const url = new URL(req.url)
47
+ if (url.pathname === '/api/apps') return Response.json([{ id: APP_ID, name: APP_NAME }])
48
+ if (url.pathname === `/api/apps/${APP_ID}/env`) return Response.json({ values: REMOTE })
49
+ if (url.pathname === '/api/secrets/vars') return Response.json(GLOBAL_VARS)
50
+ return new Response('not found', { status: 404 })
51
+ },
52
+ })
53
+ home = mkdtempSync(join(tmpdir(), 'bod-list-home-'))
54
+ cwd = mkdtempSync(join(tmpdir(), 'bod-list-cwd-'))
55
+ mkdirSync(join(home, '.bod'), { recursive: true })
56
+ writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
57
+ defaultInstance: 'test',
58
+ instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
59
+ }))
60
+ })
61
+
62
+ afterAll(() => {
63
+ server?.stop(true)
64
+ rmSync(home, { recursive: true, force: true })
65
+ rmSync(cwd, { recursive: true, force: true })
66
+ })
67
+
68
+ /** The `value` cell of one row of the masked table, for assertions that must not be
69
+ * confused by the `len` column or another row. */
70
+ function valueCell(stdout: string, key: string): string {
71
+ const row = stdout.split('\n').find(l => new RegExp(`\\|\\s*${key}\\s*\\|`).test(l))
72
+ if (!row) throw new Error(`no row for ${key} in:\n${stdout}`)
73
+ return row.split('|')[2]!.trim()
74
+ }
75
+
76
+ async function runCli(args: string[]) {
77
+ const proc = Bun.spawn(['bun', CLI, ...args], {
78
+ cwd,
79
+ env: { ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' },
80
+ stdout: 'pipe', stderr: 'pipe',
81
+ })
82
+ const [stdout, stderr, code] = await Promise.all([
83
+ new Response(proc.stdout).text(),
84
+ new Response(proc.stderr).text(),
85
+ proc.exited,
86
+ ])
87
+ return { stdout, stderr, code }
88
+ }
89
+
90
+ // NOTE: Bun.spawn gives the CLI a PIPE, not a TTY — so every test here is already the
91
+ // piped case. Masking being non-TTY-conditional is what makes these assertions hold.
92
+ test('masked by default (stdout is a pipe): keys visible, no plaintext secret anywhere', async () => {
93
+ const { code, stdout } = await runCli(['env', 'list', APP_NAME])
94
+ expect(code).toBe(0)
95
+ for (const k of Object.keys(REMOTE)) expect(stdout).toContain(k)
96
+ for (const v of Object.values(REMOTE)) if (v) expect(stdout).not.toContain(v)
97
+ })
98
+
99
+ test('reveal boundary: 19 codepoints show nothing, 20 show the last four', async () => {
100
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
101
+ const DOTS = '\u2022'.repeat(6)
102
+ expect(valueCell(stdout, 'BOUND_20')).toBe(DOTS + 'qrst')
103
+ expect(valueCell(stdout, 'BOUND_19')).toBe(DOTS)
104
+ // and nothing of the 19-char value leaks by any suffix length
105
+ for (let i = 1; i <= 19; i++) expect(valueCell(stdout, 'BOUND_19')).not.toContain('abcdefghijklmnopqrs'.slice(-i))
106
+ expect(valueCell(stdout, 'STRIPE_KEY')).toBe(DOTS + 'rStU') // 33 cp -> 4
107
+ // Length is exact and still shown — it is what spots a truncated paste.
108
+ expect(stdout).toMatch(/BOUND_19\s*\|.*\|\s*19\s*\|/)
109
+ expect(stdout).toMatch(/BOUND_20\s*\|.*\|\s*20\s*\|/)
110
+ })
111
+
112
+ test('a low-entropy value at the old boundary is no longer de-anonymised', async () => {
113
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
114
+ const DOTS = '\u2022'.repeat(6)
115
+ // The regression this threshold exists for: `production` is 10 cp and used to render
116
+ // as ••••••on / len 10, which is the whole value.
117
+ expect(valueCell(stdout, 'LOW_ENTROPY')).toBe(DOTS)
118
+ expect(maskValue('production')).toBe(DOTS)
119
+ expect(maskValue('false')).toBe(DOTS)
120
+ expect(maskValue('true')).toBe(DOTS)
121
+ })
122
+
123
+ test('an empty value renders as (empty), not as a mask', async () => {
124
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
125
+ expect(valueCell(stdout, 'EMPTY_VAL')).toBe('(empty)')
126
+ expect(valueCell(stdout, 'EMPTY_VAL')).not.toContain('\u2022')
127
+ expect(stdout).toMatch(/EMPTY_VAL\s*\|.*\|\s*0\s*\|/)
128
+ expect(maskValue('')).toBe('(empty)')
129
+ })
130
+
131
+ test('astral values are sliced by codepoint — no lone surrogate, len counts codepoints', async () => {
132
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
133
+ expect(valueCell(stdout, 'EMOJI_VAL')).toBe('\u2022'.repeat(6) + '\u{1F600}'.repeat(4))
134
+ expect(stdout).not.toContain('\uFFFD')
135
+ for (const ch of stdout) expect(ch.codePointAt(0)! >= 0xd800 && ch.codePointAt(0)! <= 0xdfff).toBe(false)
136
+ expect(stdout).toMatch(/EMOJI_VAL\s*\|.*\|\s*20\s*\|/)
137
+ })
138
+
139
+ // Every class the strip must cover. A leaked one does one of two visible harms: it
140
+ // breaks the row across lines (C0/NEL/LS/PS) or it reflows/miscounts the cell
141
+ // (bidi override, C1 CSI) — so assert BOTH: one line, and not a single such char.
142
+ const CONTROL_CASES: Array<[string, number, string, string[]]> = [
143
+ ['C0_VAL', 20, 'x', ['\r', '\n', '\t']],
144
+ ['DEL_VAL', 20, 'x', ['\u007f']],
145
+ ['C1_VAL', 20, 'x', ['\u009b', '\u0085', '\u0080']],
146
+ ['SEP_VAL', 20, 'x', ['\u2028', '\u2029']],
147
+ ['BIDI_VAL', 20, 'x', ['\u202e', '\u200f', '\u2066']],
148
+ ]
149
+
150
+ test('control chars of every class are stripped and the row stays on one line', async () => {
151
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
152
+ for (const [key, len, survivor, banned] of CONTROL_CASES) {
153
+ const cell = valueCell(stdout, key)
154
+ expect(cell).toBe('\u2022'.repeat(6) + survivor) // only the safe tail char survives
155
+ for (const c of banned) expect(cell).not.toContain(c)
156
+ // ONE line: the key, the value and the len cell all sit on a single table row.
157
+ const row = stdout.split('\n').find(l => l.includes(key))!
158
+ for (const c of banned) expect(row).not.toContain(c)
159
+ expect(row).toMatch(new RegExp(`${key}\\s*\\|[^\\n]*\\|\\s*${len}\\s*\\|`))
160
+ }
161
+ // Column alignment is intact: with no stray CSI miscounting a cell, every border
162
+ // rule is the same width. (This is what a leaked U+009B used to break.)
163
+ const rules = stdout.split('\n').filter(l => l.startsWith('+')).map(l => l.length)
164
+ expect(rules.length).toBeGreaterThan(1)
165
+ expect(new Set(rules).size).toBe(1)
166
+ })
167
+
168
+ test('--reveal prints full plaintext in the original KEY=VALUE format', async () => {
169
+ const { code, stdout } = await runCli(['env', 'list', APP_NAME, '--reveal'])
170
+ expect(code).toBe(0)
171
+ // Byte-identical to the pre-masking format: one KEY=VALUE line per var, raw values.
172
+ expect(stdout).toBe(Object.entries(REMOTE).map(([k, v]) => `${k}=${v}`).join('\n') + '\n')
173
+ })
174
+
175
+ test('the --reveal hint goes to stderr, keeping stdout parseable', async () => {
176
+ const { stdout, stderr } = await runCli(['env', 'list', APP_NAME])
177
+ expect(stderr).toContain('--reveal')
178
+ expect(stdout).not.toContain('--reveal')
179
+ })
180
+
181
+ test('--reveal is rejected on scopes that only ever hold masked server values', async () => {
182
+ const { code, stderr } = await runCli(['env', 'list', '--global', '--reveal'])
183
+ expect(code).toBe(1)
184
+ expect(stderr).toContain('--reveal only applies to the per-app scope')
185
+ })
186
+
187
+ test('--global passes the SERVER masked string through — maskValue is per-app only', async () => {
188
+ const { code, stdout } = await runCli(['env', 'list', '--global'])
189
+ expect(code).toBe(0)
190
+ for (const v of GLOBAL_VARS) {
191
+ expect(stdout).toContain(v.key)
192
+ expect(stdout).toContain(v.entries[0].masked) // verbatim, not re-masked
193
+ }
194
+ expect(stdout).not.toContain('\u2022')
195
+ expect(stdout).not.toMatch(/\blen\b/)
196
+ })
197
+
198
+ test('maskValue budget: nothing below 20 codepoints, then floor(len/5) capped at 4', () => {
199
+ const DOTS = '\u2022'.repeat(6)
200
+ expect(maskValue('4821')).toBe(DOTS)
201
+ expect(maskValue('production')).toBe(DOTS)
202
+ expect(maskValue('a'.repeat(19))).toBe(DOTS) // 19 -> still nothing
203
+ expect(maskValue('abcdefghijklmnopqrst')).toBe(DOTS + 'qrst') // 20 -> the first tail
204
+ expect(maskValue('a'.repeat(100))).toBe(DOTS + 'aaaa')
205
+ expect(maskValue('\u{1F600}'.repeat(20))).toBe(DOTS + '\u{1F600}'.repeat(4))
206
+ expect(maskValue('\u{1F600}'.repeat(19))).toBe(DOTS) // 19 codepoints (38 UTF-16 units)
207
+ // A tail that is ENTIRELY unsafe strips to nothing — reveals less, never more.
208
+ expect(maskValue('abcdefghijklmnop\u202e\u2028\u009b\r')).toBe(DOTS)
209
+ })
210
+
211
+ test('displayLength counts codepoints, matching the slicing unit', () => {
212
+ expect(displayLength('\u{1F600}'.repeat(20))).toBe(20) // not 40
213
+ expect(displayLength('abcdefghij\n')).toBe(11)
214
+ })