@localauth/cli 0.3.0 → 0.4.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/README.md CHANGED
@@ -61,6 +61,7 @@ localauth show <id> # one login with its output (--json for scri
61
61
  localauth status # account and service this machine is connected to
62
62
  localauth verify # match a number on the page to trust end-to-end encryption
63
63
  localauth logout # disconnect this machine
64
+ localauth approve --title <text> [--description <text>] [--timeout <minutes>]
64
65
  ```
65
66
 
66
67
  `localauth finish` matches the address to the waiting login by port and OAuth state, so a user
package/cli/ask.ts CHANGED
@@ -159,3 +159,60 @@ export async function ask(connection: Connection, options: AskOptions, say: (tex
159
159
  await run.close()
160
160
  }
161
161
  }
162
+
163
+ export interface ApproveOptions { title: string; description?: string; timeout: number }
164
+ export function parseApproveArgs(rest: string[]): ApproveOptions {
165
+ let options: ApproveOptions = { title: '', timeout: 15 }
166
+ for (let i = 0; i < rest.length; i++) {
167
+ let value = rest[i + 1]
168
+ if (value === undefined) throw new Error(`${rest[i]} needs a value.`)
169
+ if (rest[i] === '--title') options.title = value
170
+ else if (rest[i] === '--description') options.description = value
171
+ else if (rest[i] === '--timeout') {
172
+ options.timeout = Number(value)
173
+ if (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout > 60) throw new Error('--timeout must be greater than 0 and at most 60.')
174
+ } else throw new Error(`Unknown localauth approve option "${rest[i]}".`)
175
+ i++
176
+ }
177
+ if (!options.title) throw new Error('localauth approve needs --title.')
178
+ return options
179
+ }
180
+
181
+ /** Only an answer sealed with the pinned account key can approve; anything else that arrives can only decline. */
182
+ export async function approve(connection: Connection, options: ApproveOptions, say: (text: string) => void): Promise<number> {
183
+ if (!connection.accountPublicKey) throw new Error('localauth approve needs end-to-end encryption: add a passkey on your account, then run localauth verify.')
184
+ let expiresAt = Date.now() + Math.ceil(options.timeout * 60_000)
185
+ let request: AskRequest = { title: options.title, description: options.description, fields: [{ name: 'APPROVE', label: 'Approve', secret: false, required: true }] }
186
+ let data: RunData = { authenticatedMessages: true, kind: 'ask', approval: true, title: options.title, ask: request, expiresAt, command: '', flows: [], output: '', exitCode: null, attempt: 1, retryUntil: null }
187
+ if (!isAskRequest(data.ask)) throw new Error('Invalid approval request.')
188
+ let run = await createRun(connection, [], say, { retry: false, link: false }, data)
189
+ let stop = new AbortController(), interrupted = 0
190
+ let listeners = Object.entries({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }).map(([signal, code]) => {
191
+ let listener = () => { interrupted = code; stop.abort() }
192
+ process.on(signal, listener); return { signal, listener }
193
+ })
194
+ try {
195
+ await run.publish()
196
+ let decided: boolean | null = null
197
+ while (!stop.signal.aborted && Date.now() < expiresAt && decided === null) {
198
+ let signal = AbortSignal.any([stop.signal, AbortSignal.timeout(Math.max(1, expiresAt - Date.now()))])
199
+ try {
200
+ for (let message of await run.takeMessages(signal)) {
201
+ if (message.kind === 'decline') { decided = false; break }
202
+ if (message.kind !== 'answer' || typeof message.data !== 'string' || !message.data.startsWith('a1.')) continue
203
+ let value = await run.openMessage<AnswerData>('answer', message.data)
204
+ if (isAnswer(value) && !value.declined && answerFields(value, request)?.APPROVE === 'yes') { decided = true; break }
205
+ }
206
+ if (decided === null) await sleep(Math.min(1500, Math.max(1, expiresAt - Date.now())), undefined, { signal })
207
+ } catch (error) { if (!signal.aborted) throw error }
208
+ }
209
+ data.exitCode = decided ? 0 : 1
210
+ await run.publish(AbortSignal.timeout(3000)).catch(() => {})
211
+ if (interrupted) return interrupted
212
+ say(decided ? 'localauth: approved.' : decided === false ? 'localauth: the owner declined the request.' : 'localauth: the request timed out.')
213
+ return decided ? 0 : 1
214
+ } finally {
215
+ for (let { signal, listener } of listeners) process.off(signal, listener)
216
+ await run.close()
217
+ }
218
+ }
package/cli/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ask, parseAskArgs } from './ask.ts'
1
+ import { ask, parseAskArgs, approve, parseApproveArgs } from './ask.ts'
2
2
  import * as os from 'node:os'
3
3
  import { createTerminal } from 'remix/terminal'
4
4
 
@@ -32,6 +32,10 @@ Usage:
32
32
  LD_*, NODE_OPTIONS, BROWSER, LOCALAUTH_* or other loader variables
33
33
  Title/labels: max 120; description: max 2000 (plain text + links)
34
34
  Requests expire in 15 minutes (max 60); decline/timeout exits 1
35
+ localauth approve --title <text> [--description <text>] [--timeout <minutes>]
36
+ Wait for the owner to allow something. They answer from their
37
+ signed-in list with a fresh passkey check; no link is made
38
+ Needs end-to-end encryption; exits 0 if approved, 1 otherwise
35
39
  localauth finish [<callback-url>] Hand a pasted http://localhost:... address to the waiting command
36
40
  (reads it from stdin when omitted, keeping it out of ps)
37
41
  localauth list [--json] This machine's logins and requests
@@ -78,6 +82,9 @@ export async function main(args: string[]): Promise<number> {
78
82
  case 'ask':
79
83
  return await ask(requireConnection(), parseAskArgs(rest), say)
80
84
 
85
+ case 'approve':
86
+ return await approve(requireConnection(), parseApproveArgs(rest), say)
87
+
81
88
  case 'browser':
82
89
  return await reportBrowserUrls(rest, say)
83
90
 
package/cli/run.ts CHANGED
@@ -159,7 +159,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
159
159
  snapshot.output = redactor.redact(snapshot.output)
160
160
  for (let flow of snapshot.flows) flow.note = redactor.redact(flow.note)
161
161
  let sealed = await encodeRun(connection!, id, snapshot, signal)
162
- let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
162
+ let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.approval ? { approval: true } : {}), ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
163
163
  if (!offline) say(`localauth: ${(error as Error).message}`)
164
164
  offline = true
165
165
  return null
@@ -253,7 +253,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
253
253
  if (reply.status === 404) throw new Error('This request expired or was dismissed.')
254
254
  return reply.status === 200 ? reply.data.data : []
255
255
  },
256
- async openMessage<T>(kind: MessageKind, value: unknown) {
256
+ async openMessage<T>(kind: Exclude<MessageKind, 'decline'>, value: unknown) {
257
257
  return openMessage<T>({ privateKey: key!, publicKey: connection!.publicKey, accountPublicKey: connection?.accountPublicKey, linkKey: link?.key }, kind, id, value)
258
258
  },
259
259
  async takeRetry(signal: AbortSignal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localauth/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Finish CLI logins on a headless server from your phone, with optional passkey-based end-to-end encryption",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "build": "node scripts/build-client.ts",
41
41
  "deploy": "wrangler deploy",
42
42
  "test": "NODE_ENV=test remix test",
43
- "typecheck": "tsc --noEmit",
43
+ "typecheck": "tsc --noEmit && tsc --noEmit -p app/landing-demo",
44
44
  "db:generate": "drizzle-kit generate",
45
45
  "db:migrate:local": "wrangler d1 migrations apply localauth --local",
46
46
  "db:migrate": "wrangler d1 migrations apply localauth --remote",
@@ -52,6 +52,7 @@
52
52
  "node": ">=24.3.0"
53
53
  },
54
54
  "dependencies": {
55
+ "@simplewebauthn/server": "13.3.3",
55
56
  "remix": "^3.0.0"
56
57
  },
57
58
  "devDependencies": {
@@ -62,6 +63,8 @@
62
63
  "@remix-run/ui": "0.12.1",
63
64
  "@simplewebauthn/browser": "^13.3.0",
64
65
  "@types/node": "^26.6.5",
66
+ "@types/react": "18.3.12",
67
+ "@types/react-dom": "18.3.1",
65
68
  "auth": "1.7.12",
66
69
  "better-auth": "1.7.12",
67
70
  "drizzle-kit": "^0.31.11",
@@ -70,6 +73,7 @@
70
73
  "jose": "^6.2.12",
71
74
  "patch-package": "8.0.1",
72
75
  "playwright-core": "^1.64.1",
76
+ "preact": "10.25.4",
73
77
  "typescript": "^7.0.2",
74
78
  "wrangler": "^4.151.0"
75
79
  }
@@ -21,7 +21,7 @@ export interface Flow {
21
21
  }
22
22
 
23
23
  /** Login details: plaintext in the owner API by default, opaque in end-to-end mode. */
24
- export type MessageKind = 'callback' | 'input' | 'answer'
24
+ export type MessageKind = 'callback' | 'input' | 'answer' | 'decline'
25
25
  export interface InputData { attempt: number; text: string; messageId: string }
26
26
  export interface AskField { name: string; label: string; secret: boolean; required: boolean }
27
27
  export interface AskRequest { title?: string; description?: string; fields: AskField[] }
@@ -33,6 +33,8 @@ export type AnswerData = { declined: true; messageId: string }
33
33
  export interface RunData {
34
34
  authenticatedMessages?: boolean
35
35
  kind?: 'login' | 'ask'
36
+ /** Answered only with a fresh passkey check, never by typing or through a link. */
37
+ approval?: boolean
36
38
  inputEnabled?: boolean
37
39
  secret?: boolean
38
40
  ask?: AskRequest
@@ -10,6 +10,7 @@ export function isRunData(value: unknown): value is RunData {
10
10
  return !!v && typeof v.title === 'string' && typeof v.command === 'string' && typeof v.output === 'string'
11
11
  && (v.authenticatedMessages === undefined || typeof v.authenticatedMessages === 'boolean')
12
12
  && (v.kind === undefined || ['login', 'ask'].includes(v.kind))
13
+ && (v.approval === undefined || (v.approval === true && v.kind === 'ask'))
13
14
  && (v.inputEnabled === undefined || typeof v.inputEnabled === 'boolean')
14
15
  && (v.secret === undefined || typeof v.secret === 'boolean')
15
16
  && (v.ask === undefined || (v.kind === 'ask' && isAskRequest(v.ask)))
@@ -185,3 +185,12 @@ nothing to paste, the command finishes on its own once the user approves.
185
185
  title on the page (Kargo, Codex, …).
186
186
  - If the user gives up, stop the background `localauth run` process so the command stops listening.
187
187
  - `localauth help` lists every command.
188
+
189
+ ## Asking the owner to allow something
190
+
191
+ ```sh
192
+ localauth approve --title "Allow access to the production database" --description "Why it is needed"
193
+ ```
194
+
195
+ It exits 0 if approved and 1 if declined or timed out. The owner answers from their signed-in list with a new passkey check, so there is no link to send them: tell them to open localauth.dev. It needs end-to-end encryption on the account.
196
+