@owlmeans/cli-auth 0.1.18-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/cli-auth/SKILL.md +100 -0
- package/build/consts.d.ts +10 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +10 -0
- package/build/consts.js.map +1 -0
- package/build/env-file.d.ts +42 -0
- package/build/env-file.d.ts.map +1 -0
- package/build/env-file.js +107 -0
- package/build/env-file.js.map +1 -0
- package/build/holder.d.ts +38 -0
- package/build/holder.d.ts.map +1 -0
- package/build/holder.js +153 -0
- package/build/holder.js.map +1 -0
- package/build/index.d.ts +6 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/lock.d.ts +39 -0
- package/build/lock.d.ts.map +1 -0
- package/build/lock.js +49 -0
- package/build/lock.js.map +1 -0
- package/build/open-browser.d.ts +11 -0
- package/build/open-browser.d.ts.map +1 -0
- package/build/open-browser.js +40 -0
- package/build/open-browser.js.map +1 -0
- package/package.json +38 -0
- package/src/consts.ts +12 -0
- package/src/env-file.ts +123 -0
- package/src/holder.ts +215 -0
- package/src/index.ts +5 -0
- package/src/lock.ts +77 -0
- package/src/open-browser.ts +40 -0
- package/tests/env-file.spec.ts +110 -0
- package/tests/holder.spec.ts +242 -0
- package/tests/lock.spec.ts +85 -0
- package/tests/open-browser.spec.ts +14 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { loadOwlmeansEnv, parseEnv, readCredentialsFile, resolveEnvFile, setEnvValues } from '../src/env-file.js'
|
|
6
|
+
import { ENV_CREDENTIALS_FILE } from '../src/consts.js'
|
|
7
|
+
|
|
8
|
+
let dir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cli-auth-test-')) })
|
|
11
|
+
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
|
|
12
|
+
|
|
13
|
+
describe('resolveEnvFile', () => {
|
|
14
|
+
test('defaults to ~/.owlmeans', () => {
|
|
15
|
+
expect(resolveEnvFile({})).toMatch(/\.owlmeans$/)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('honors OWLMEANS_CREDENTIALS', () => {
|
|
19
|
+
expect(resolveEnvFile({ [ENV_CREDENTIALS_FILE]: '/custom/path' })).toBe('/custom/path')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('ignores an empty override', () => {
|
|
23
|
+
expect(resolveEnvFile({ [ENV_CREDENTIALS_FILE]: '' })).toMatch(/\.owlmeans$/)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('parseEnv', () => {
|
|
28
|
+
test('reads KEY=VALUE, quotes, comments and export prefixes', () => {
|
|
29
|
+
const parsed = parseEnv([
|
|
30
|
+
'# a comment', '', 'FOO=bar', 'export BAZ=qux', 'QUOTED="has spaces"', "SINGLE='also quoted'",
|
|
31
|
+
].join('\n'))
|
|
32
|
+
expect(parsed).toEqual({ FOO: 'bar', BAZ: 'qux', QUOTED: 'has spaces', SINGLE: 'also quoted' })
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('loadOwlmeansEnv', () => {
|
|
37
|
+
test('the environment wins over the file', async () => {
|
|
38
|
+
const path = join(dir, '.owlmeans')
|
|
39
|
+
await setEnvValues(path, { VIABLE_API_TOKEN: 'from-file' })
|
|
40
|
+
|
|
41
|
+
const merged = await loadOwlmeansEnv({ [ENV_CREDENTIALS_FILE]: path, VIABLE_API_TOKEN: 'from-env' })
|
|
42
|
+
expect(merged.VIABLE_API_TOKEN).toBe('from-env')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('an empty environment value does not shadow the file', async () => {
|
|
46
|
+
const path = join(dir, '.owlmeans')
|
|
47
|
+
await setEnvValues(path, { VIABLE_API_TOKEN: 'from-file' })
|
|
48
|
+
|
|
49
|
+
const merged = await loadOwlmeansEnv({ [ENV_CREDENTIALS_FILE]: path, VIABLE_API_TOKEN: '' })
|
|
50
|
+
expect(merged.VIABLE_API_TOKEN).toBe('from-file')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a missing file is not an error', async () => {
|
|
54
|
+
const merged = await loadOwlmeansEnv({ [ENV_CREDENTIALS_FILE]: join(dir, 'nope') })
|
|
55
|
+
expect(merged.VIABLE_API_TOKEN).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('setEnvValues', () => {
|
|
60
|
+
test('creates the file with mode 0600', async () => {
|
|
61
|
+
const path = join(dir, '.owlmeans')
|
|
62
|
+
const result = await setEnvValues(path, { A: '1' })
|
|
63
|
+
expect(result.insecurePermissions).toBe(false)
|
|
64
|
+
|
|
65
|
+
const info = await stat(path)
|
|
66
|
+
expect(info.mode & 0o777).toBe(0o600)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('preserves comments and untouched keys, replaces the named one in place', async () => {
|
|
70
|
+
const path = join(dir, '.owlmeans')
|
|
71
|
+
await Bun.write(path, '# a note\nKEEP=1\nCHANGE=old\n')
|
|
72
|
+
|
|
73
|
+
await setEnvValues(path, { CHANGE: 'new' })
|
|
74
|
+
const content = await readFile(path, 'utf-8')
|
|
75
|
+
expect(content).toContain('# a note')
|
|
76
|
+
expect(content).toContain('KEEP=1')
|
|
77
|
+
expect(content).toContain('CHANGE=new')
|
|
78
|
+
expect(content).not.toContain('CHANGE=old')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('appends a key that was not there before', async () => {
|
|
82
|
+
const path = join(dir, '.owlmeans')
|
|
83
|
+
await Bun.write(path, 'A=1\n')
|
|
84
|
+
await setEnvValues(path, { B: '2' })
|
|
85
|
+
|
|
86
|
+
const parsed = await readCredentialsFile({ [ENV_CREDENTIALS_FILE]: path })
|
|
87
|
+
expect(parsed).toEqual({ A: '1', B: '2' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('an undefined value removes the key rather than writing it', async () => {
|
|
91
|
+
const path = join(dir, '.owlmeans')
|
|
92
|
+
await Bun.write(path, 'A=1\nB=2\n')
|
|
93
|
+
await setEnvValues(path, { A: undefined })
|
|
94
|
+
|
|
95
|
+
const parsed = await readCredentialsFile({ [ENV_CREDENTIALS_FILE]: path })
|
|
96
|
+
expect(parsed).toEqual({ B: '2' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('a concurrent rewrite never leaves a half-written file — the temp file lands atomically', async () => {
|
|
100
|
+
const path = join(dir, '.owlmeans')
|
|
101
|
+
await Promise.all([
|
|
102
|
+
setEnvValues(path, { A: '1' }),
|
|
103
|
+
setEnvValues(path, { B: '2' }),
|
|
104
|
+
])
|
|
105
|
+
// Whichever write landed last, the file must be one complete, parseable write — never a
|
|
106
|
+
// truncated interleaving of the two.
|
|
107
|
+
const content = await readFile(path, 'utf-8')
|
|
108
|
+
expect(content.endsWith('\n')).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { makeCliCredentials } from '../src/holder.js'
|
|
6
|
+
import { readCredentialsFile } from '../src/env-file.js'
|
|
7
|
+
import { ENV_CREDENTIALS_FILE } from '../src/consts.js'
|
|
8
|
+
|
|
9
|
+
const originalFetch = globalThis.fetch
|
|
10
|
+
const CLIENT_ID = 'test-cli'
|
|
11
|
+
let ISSUER: string
|
|
12
|
+
let testSeq = 0
|
|
13
|
+
|
|
14
|
+
let dir: string
|
|
15
|
+
let credentialsPath: string
|
|
16
|
+
|
|
17
|
+
const asMetadata = (issuer: string) => ({
|
|
18
|
+
issuer,
|
|
19
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
20
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
21
|
+
device_authorization_endpoint: `${issuer}/oauth/device_authorization`,
|
|
22
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
23
|
+
scopes_supported: ['*'],
|
|
24
|
+
response_types_supported: ['code'],
|
|
25
|
+
grant_types_supported: ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code'],
|
|
26
|
+
code_challenge_methods_supported: ['S256'],
|
|
27
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
28
|
+
client_id_metadata_document_supported: true,
|
|
29
|
+
authorization_response_iss_parameter_supported: true,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const json = (body: unknown, status = 200): Response =>
|
|
33
|
+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), 'cli-auth-holder-'))
|
|
37
|
+
credentialsPath = join(dir, '.owlmeans')
|
|
38
|
+
// A distinct host per test: `inFlightByApiUrl` is module-level state, and a test that
|
|
39
|
+
// deliberately times out (SignInRequired) leaves its background poll running past its own
|
|
40
|
+
// `afterEach` — a shared apiUrl would let that poll's later activity leak into the next test.
|
|
41
|
+
ISSUER = `https://api-${++testSeq}.example.com`
|
|
42
|
+
})
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
globalThis.fetch = originalFetch
|
|
45
|
+
await rm(dir, { recursive: true, force: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** A fake server: metadata, one device_authorization call, and a token endpoint whose answer
|
|
49
|
+
* `pendingUntil` polls can advance past `authorization_pending` on demand. */
|
|
50
|
+
const fakeServer = (opts: { authorizeAfterPolls?: number } = {}) => {
|
|
51
|
+
let deviceCalls = 0
|
|
52
|
+
let tokenCalls = 0
|
|
53
|
+
const authorizeAfterPolls = opts.authorizeAfterPolls ?? 0
|
|
54
|
+
|
|
55
|
+
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
|
56
|
+
const href = url.toString()
|
|
57
|
+
if (href.endsWith('/.well-known/oauth-authorization-server')) return json(asMetadata(ISSUER))
|
|
58
|
+
if (href.endsWith('/oauth/device_authorization')) {
|
|
59
|
+
deviceCalls += 1
|
|
60
|
+
|
|
61
|
+
return json({
|
|
62
|
+
device_code: `dc-${deviceCalls}`, user_code: `CODE-${deviceCalls}`,
|
|
63
|
+
verification_uri: `${ISSUER}/oauth/device`, expires_in: 600, interval: 0.001,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
if (href.endsWith('/oauth/token')) {
|
|
67
|
+
tokenCalls += 1
|
|
68
|
+
|
|
69
|
+
return tokenCalls > authorizeAfterPolls
|
|
70
|
+
? json({ access_token: 'vib_new-token', token_type: 'Bearer', expires_in: 100 })
|
|
71
|
+
: json({ error: 'authorization_pending' }, 400)
|
|
72
|
+
}
|
|
73
|
+
if (href.endsWith('/oauth/revoke')) return new Response(null, { status: 200 })
|
|
74
|
+
|
|
75
|
+
throw new Error(`unexpected fetch: ${href} ${init?.method ?? 'GET'}`)
|
|
76
|
+
}) as typeof fetch
|
|
77
|
+
|
|
78
|
+
return { deviceCallCount: () => deviceCalls }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const opts = (extra: Record<string, unknown> = {}) => ({
|
|
82
|
+
apiUrl: ISSUER, clientId: CLIENT_ID, tokenEnvKey: 'VIABLE_API_TOKEN', apiUrlEnvKey: 'VIABLE_API_URL',
|
|
83
|
+
env: { [ENV_CREDENTIALS_FILE]: credentialsPath } as unknown as NodeJS.ProcessEnv,
|
|
84
|
+
...extra,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('token()', () => {
|
|
88
|
+
test('prefers the environment over the file', async () => {
|
|
89
|
+
const credentials = makeCliCredentials(opts({
|
|
90
|
+
env: { [ENV_CREDENTIALS_FILE]: credentialsPath, VIABLE_API_TOKEN: 'from-env' } as unknown as NodeJS.ProcessEnv,
|
|
91
|
+
}))
|
|
92
|
+
expect(await credentials.token()).toBe('from-env')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('falls back to a bound file token', async () => {
|
|
96
|
+
fakeServer()
|
|
97
|
+
const credentials = makeCliCredentials(opts())
|
|
98
|
+
await credentials.require()
|
|
99
|
+
expect(await credentials.token()).toBe('vib_new-token')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('refuses a file token bound to a different API URL', async () => {
|
|
103
|
+
const { setEnvValues } = await import('../src/env-file.js')
|
|
104
|
+
await setEnvValues(credentialsPath, { VIABLE_API_TOKEN: 'vib_other', VIABLE_API_URL: 'https://other.example.com' })
|
|
105
|
+
|
|
106
|
+
const credentials = makeCliCredentials(opts())
|
|
107
|
+
expect(await credentials.token()).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('accepts a file token when no URL was ever recorded', async () => {
|
|
111
|
+
const { setEnvValues } = await import('../src/env-file.js')
|
|
112
|
+
await setEnvValues(credentialsPath, { VIABLE_API_TOKEN: 'vib_no-url-recorded' })
|
|
113
|
+
|
|
114
|
+
const credentials = makeCliCredentials(opts())
|
|
115
|
+
expect(await credentials.token()).toBe('vib_no-url-recorded')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('require()', () => {
|
|
120
|
+
test('leaves no ceiling timer behind once signed in — a pending timer would keep `login` alive', async () => {
|
|
121
|
+
fakeServer()
|
|
122
|
+
const CEILING = 987_654
|
|
123
|
+
const armed = new Set<unknown>()
|
|
124
|
+
const realSet = globalThis.setTimeout
|
|
125
|
+
const realClear = globalThis.clearTimeout
|
|
126
|
+
globalThis.setTimeout = ((fn: () => void, ms?: number, ...rest: unknown[]) => {
|
|
127
|
+
const handle = realSet(fn, ms, ...rest)
|
|
128
|
+
if (ms === CEILING) armed.add(handle)
|
|
129
|
+
|
|
130
|
+
return handle
|
|
131
|
+
}) as typeof setTimeout
|
|
132
|
+
globalThis.clearTimeout = ((handle: Parameters<typeof clearTimeout>[0]) => {
|
|
133
|
+
armed.delete(handle)
|
|
134
|
+
realClear(handle)
|
|
135
|
+
}) as typeof clearTimeout
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const credentials = makeCliCredentials(opts())
|
|
139
|
+
expect(await credentials.require(CEILING)).toBe('vib_new-token')
|
|
140
|
+
expect(armed.size).toBe(0)
|
|
141
|
+
} finally {
|
|
142
|
+
globalThis.setTimeout = realSet
|
|
143
|
+
globalThis.clearTimeout = realClear
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('signs in and persists the token bound to this API URL', async () => {
|
|
148
|
+
fakeServer()
|
|
149
|
+
const notifications: string[] = []
|
|
150
|
+
const credentials = makeCliCredentials(opts({ onNotify: (m: string) => notifications.push(m) }))
|
|
151
|
+
|
|
152
|
+
const token = await credentials.require(5_000)
|
|
153
|
+
expect(token).toBe('vib_new-token')
|
|
154
|
+
expect(notifications.some(m => m.includes('CODE-1'))).toBe(true)
|
|
155
|
+
expect(notifications).toContain('Signed in.')
|
|
156
|
+
|
|
157
|
+
const file = await readCredentialsFile({ [ENV_CREDENTIALS_FILE]: credentialsPath })
|
|
158
|
+
expect(file.VIABLE_API_TOKEN).toBe('vib_new-token')
|
|
159
|
+
expect(file.VIABLE_API_URL).toBe(ISSUER)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('polls through authorization_pending until approved', async () => {
|
|
163
|
+
fakeServer({ authorizeAfterPolls: 3 })
|
|
164
|
+
const credentials = makeCliCredentials(opts())
|
|
165
|
+
expect(await credentials.require(5_000)).toBe('vib_new-token')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('returns immediately once a token already exists — no network call at all', async () => {
|
|
169
|
+
let called = false
|
|
170
|
+
globalThis.fetch = (async () => { called = true; throw new Error('should not be called') }) as typeof fetch
|
|
171
|
+
const credentials = makeCliCredentials(opts({
|
|
172
|
+
env: { [ENV_CREDENTIALS_FILE]: credentialsPath, VIABLE_API_TOKEN: 'already-here' } as unknown as NodeJS.ProcessEnv,
|
|
173
|
+
}))
|
|
174
|
+
|
|
175
|
+
expect(await credentials.require()).toBe('already-here')
|
|
176
|
+
expect(called).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('throws SignInRequired when nobody approves within the wait, naming the URL and code', async () => {
|
|
180
|
+
fakeServer({ authorizeAfterPolls: 1_000_000 })
|
|
181
|
+
const credentials = makeCliCredentials(opts())
|
|
182
|
+
|
|
183
|
+
let caught: any
|
|
184
|
+
try {
|
|
185
|
+
await credentials.require(50)
|
|
186
|
+
} catch (e) {
|
|
187
|
+
caught = e
|
|
188
|
+
}
|
|
189
|
+
expect(caught).toBeDefined()
|
|
190
|
+
expect(caught.url).toContain('/oauth/device')
|
|
191
|
+
expect(caught.code).toBe('CODE-1')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('a second require() call while the first is pending only requests ONE device code', async () => {
|
|
195
|
+
const server = fakeServer({ authorizeAfterPolls: 2 })
|
|
196
|
+
const credentials = makeCliCredentials(opts())
|
|
197
|
+
|
|
198
|
+
const [a, b] = await Promise.all([credentials.require(5_000), credentials.require(5_000)])
|
|
199
|
+
expect(a).toBe('vib_new-token')
|
|
200
|
+
expect(b).toBe('vib_new-token')
|
|
201
|
+
expect(server.deviceCallCount()).toBe(1)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('invalidate()', () => {
|
|
206
|
+
test('forgets a rejected file token so the next require() signs in again', async () => {
|
|
207
|
+
fakeServer()
|
|
208
|
+
const credentials = makeCliCredentials(opts())
|
|
209
|
+
await credentials.require()
|
|
210
|
+
|
|
211
|
+
await credentials.invalidate('vib_new-token')
|
|
212
|
+
expect(await credentials.token()).toBeNull()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('reports (never silently switches away from) a rejected environment token', async () => {
|
|
216
|
+
const credentials = makeCliCredentials(opts({
|
|
217
|
+
env: { [ENV_CREDENTIALS_FILE]: credentialsPath, VIABLE_API_TOKEN: 'from-env' } as unknown as NodeJS.ProcessEnv,
|
|
218
|
+
}))
|
|
219
|
+
await expect(credentials.invalidate('from-env')).rejects.toThrow()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('does nothing for a token that is neither the current file nor env value', async () => {
|
|
223
|
+
const credentials = makeCliCredentials(opts())
|
|
224
|
+
await expect(credentials.invalidate('unrelated-token')).resolves.toBeUndefined()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('signOut()', () => {
|
|
229
|
+
test('revokes at the server and clears the file', async () => {
|
|
230
|
+
fakeServer()
|
|
231
|
+
const credentials = makeCliCredentials(opts())
|
|
232
|
+
await credentials.require()
|
|
233
|
+
|
|
234
|
+
await credentials.signOut()
|
|
235
|
+
expect(await credentials.token()).toBeNull()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('is a no-op with nothing signed in', async () => {
|
|
239
|
+
const credentials = makeCliCredentials(opts())
|
|
240
|
+
await expect(credentials.signOut()).resolves.toBeUndefined()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { claimOrJoinLock, readLock, releaseLock } from '../src/lock.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
let path: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
dir = await mkdtemp(join(tmpdir(), 'cli-auth-lock-'))
|
|
12
|
+
path = join(dir, '.owlmeans.lock')
|
|
13
|
+
})
|
|
14
|
+
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
|
|
15
|
+
|
|
16
|
+
const info = (overrides: Record<string, unknown> = {}) => ({
|
|
17
|
+
verificationUri: 'https://app.example.com/oauth/device', userCode: 'ABCD-EFGH',
|
|
18
|
+
deviceCode: 'secret-device-code', interval: 5, expiresAt: Date.now() + 60_000, ...overrides,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('claimOrJoinLock', () => {
|
|
22
|
+
test('claims an absent lock', async () => {
|
|
23
|
+
const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
|
|
24
|
+
expect(claim.owner).toBe(true)
|
|
25
|
+
expect(claim.info.apiUrl).toBe('https://api.example.com')
|
|
26
|
+
expect(claim.info.pid).toBe(process.pid)
|
|
27
|
+
|
|
28
|
+
expect(await readLock(path)).toEqual(claim.info)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('joins a live lock for the same API URL instead of overwriting it', async () => {
|
|
32
|
+
const first = await claimOrJoinLock(path, 'https://api.example.com', info({ userCode: 'FIRST-CODE' }))
|
|
33
|
+
expect(first.owner).toBe(true)
|
|
34
|
+
|
|
35
|
+
const second = await claimOrJoinLock(path, 'https://api.example.com', info({ userCode: 'SECOND-CODE' }))
|
|
36
|
+
expect(second.owner).toBe(false)
|
|
37
|
+
expect(second.info.userCode).toBe('FIRST-CODE')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('reclaims a lock for a different API URL', async () => {
|
|
41
|
+
await claimOrJoinLock(path, 'https://one.example.com', info())
|
|
42
|
+
const claim = await claimOrJoinLock(path, 'https://two.example.com', info())
|
|
43
|
+
expect(claim.owner).toBe(true)
|
|
44
|
+
expect(claim.info.apiUrl).toBe('https://two.example.com')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('reclaims an expired lock', async () => {
|
|
48
|
+
await claimOrJoinLock(path, 'https://api.example.com', info({ expiresAt: Date.now() - 1 }))
|
|
49
|
+
const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
|
|
50
|
+
expect(claim.owner).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('reclaims a lock left by a process that is no longer running', async () => {
|
|
54
|
+
// A pid essentially guaranteed not to be alive in this test's own pid namespace.
|
|
55
|
+
await writeFile(path, JSON.stringify({
|
|
56
|
+
...info(), apiUrl: 'https://api.example.com', pid: 999_999, nonce: 'stale',
|
|
57
|
+
}))
|
|
58
|
+
const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
|
|
59
|
+
expect(claim.owner).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('releaseLock', () => {
|
|
64
|
+
test('removes only the lock this process itself wrote', async () => {
|
|
65
|
+
const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
|
|
66
|
+
await releaseLock(path, claim.info.nonce)
|
|
67
|
+
expect(await readLock(path)).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('never removes a lock somebody else has since claimed', async () => {
|
|
71
|
+
const first = await claimOrJoinLock(path, 'https://one.example.com', info())
|
|
72
|
+
await claimOrJoinLock(path, 'https://two.example.com', info()) // reclaims the path, new nonce
|
|
73
|
+
|
|
74
|
+
await releaseLock(path, first.info.nonce)
|
|
75
|
+
expect((await readLock(path))?.apiUrl).toBe('https://two.example.com')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('readLock', () => {
|
|
80
|
+
test('answers null for a missing or malformed file', async () => {
|
|
81
|
+
expect(await readLock(path)).toBeNull()
|
|
82
|
+
await writeFile(path, 'not json')
|
|
83
|
+
expect(await readLock(path)).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { openBrowser } from '../src/open-browser.js'
|
|
3
|
+
|
|
4
|
+
describe('openBrowser suppression', () => {
|
|
5
|
+
test('OWLMEANS_NO_BROWSER and BROWSER=none refuse without spawning anything', () => {
|
|
6
|
+
expect(openBrowser('https://example.test/', { OWLMEANS_NO_BROWSER: '1' })).toBe(false)
|
|
7
|
+
expect(openBrowser('https://example.test/', { BROWSER: 'none' })).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('a Linux session with no display refuses', () => {
|
|
11
|
+
if (process.platform !== 'linux') return
|
|
12
|
+
expect(openBrowser('https://example.test/', {})).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
|
|
11
|
+
}
|