@lpm-registry/cli 0.7.0 → 0.9.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.8.0] - 2026-04-01
6
+
7
+ ### Changed
8
+
9
+ - **Rust binary v0.8.0** — Install hash fast-exit (82ms → 26ms up-to-date), NDJSON streaming batch metadata, resolver carries tarball URLs (eliminates double metadata fetch), up-to-date benchmark added.
10
+ - **Scoped registry default** — `lpm setup` and `lpm npmrc` now write scoped config (`@lpm.dev:registry=`) by default. npm packages go direct to npmjs.org (faster, $0 proxy cost). Use `--proxy` to opt into full proxy mode (Pro/Org feature).
11
+ - **Global config defaults** — `lpm config set allowNew true`, `lpm config set autoBuild true`, etc. CLI flags merge with `~/.lpm/config.toml` defaults.
12
+ - **Migrate fix** — Corrected stale registry URL in `lpm migrate` (.npmrc now writes `/api/registry/` instead of `/api/packages/`).
13
+ - Version bump to stay in sync with Rust binary releases.
14
+
5
15
  ## [0.7.0] - 2026-04-01
6
16
 
7
17
  ### Changed
package/lib/api.js CHANGED
@@ -9,7 +9,15 @@
9
9
  * @module cli/lib/api
10
10
  */
11
11
 
12
- import { getRegistryUrl, getRetries, getTimeout, getToken } from "./config.js"
12
+ import {
13
+ getRefreshToken,
14
+ getRegistryUrl,
15
+ getRetries,
16
+ getTimeout,
17
+ getToken,
18
+ setRefreshToken,
19
+ setToken,
20
+ } from "./config.js"
13
21
  import {
14
22
  ERROR_MESSAGES,
15
23
  RATE_LIMIT_STATUS_CODES,
@@ -63,6 +71,44 @@ function parseRetryAfter(retryAfter) {
63
71
  return RETRY_BASE_DELAY_MS
64
72
  }
65
73
 
74
+ /**
75
+ * Attempt silent token refresh using the stored refresh token (Feature 44 Part B).
76
+ * @returns {Promise<string|null>} New access token if successful, null otherwise
77
+ */
78
+ async function tryRefresh() {
79
+ try {
80
+ const refreshToken = await getRefreshToken()
81
+ if (!refreshToken) return null
82
+
83
+ const registryUrl = getRegistryUrl()
84
+ const { createHash } = await import("node:crypto")
85
+ const { hostname, userInfo } = await import("node:os")
86
+ const fp = createHash("sha256")
87
+ .update(`${hostname()}:${userInfo().username}:lpm-cli`)
88
+ .digest("hex")
89
+
90
+ const resp = await fetch(`${registryUrl}/api/cli/refresh`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ refreshToken, deviceFingerprint: fp }),
94
+ })
95
+
96
+ if (!resp.ok) return null
97
+
98
+ const data = await resp.json()
99
+ if (data.token) {
100
+ await setToken(data.token)
101
+ if (data.refreshToken) {
102
+ await setRefreshToken(data.refreshToken)
103
+ }
104
+ return data.token
105
+ }
106
+ } catch {
107
+ // Refresh failed — fall through to auth error
108
+ }
109
+ return null
110
+ }
111
+
66
112
  /**
67
113
  * @typedef {Object} RequestOptions
68
114
  * @property {Record<string, string>} [headers] - Request headers
@@ -113,9 +159,8 @@ export async function request(path, options = {}) {
113
159
 
114
160
  clearTimeout(timeoutId)
115
161
 
116
- // Handle authentication errors immediately (no retry)
117
- // Exception: 2FA OTP responses (401 with OTP_REQUIRED/OTP_INVALID code)
118
- // must be returned to the caller so the publish handler can prompt for OTP
162
+ // Handle authentication errors — try silent refresh before failing
163
+ // Exception: 2FA OTP responses must be returned to the caller
119
164
  if (response.status === 401) {
120
165
  const cloned = response.clone()
121
166
  let data = {}
@@ -127,6 +172,16 @@ export async function request(path, options = {}) {
127
172
  if (data.code === "OTP_REQUIRED" || data.code === "OTP_INVALID") {
128
173
  return response
129
174
  }
175
+
176
+ // Attempt silent refresh (Feature 44 Part B) — one retry on 401
177
+ if (attempt === 0) {
178
+ const refreshed = await tryRefresh()
179
+ if (refreshed) {
180
+ // Update the auth header and retry this request
181
+ headers.Authorization = `Bearer ${refreshed}`
182
+ continue
183
+ }
184
+ }
130
185
  throw new Error(ERROR_MESSAGES.notAuthenticated)
131
186
  }
132
187
 
@@ -1,7 +1,9 @@
1
+ import { createHash } from "node:crypto"
1
2
  import http from "node:http"
3
+ import { hostname, userInfo } from "node:os"
2
4
  import open from "open"
3
5
  import { request } from "../api.js"
4
- import { getRegistryUrl, setToken } from "../config.js"
6
+ import { getRegistryUrl, setRefreshToken, setToken } from "../config.js"
5
7
  import { WARNING_MESSAGES } from "../constants.js"
6
8
  import { createSpinner, log, printHeader } from "../ui.js"
7
9
 
@@ -188,11 +190,52 @@ export async function login() {
188
190
  res.setHeader("Access-Control-Allow-Origin", "*")
189
191
 
190
192
  if (url.pathname === "/callback") {
191
- const token = url.searchParams.get("token")
193
+ // Exchange code flow (Feature 42) — code is exchanged for token server-side
194
+ const exchangeCode = url.searchParams.get("code")
195
+ // Legacy fallback: direct token in URL (for older server versions)
196
+ const directToken = url.searchParams.get("token")
197
+
198
+ let token = directToken
199
+ let refreshToken = null
200
+ if (exchangeCode && !directToken) {
201
+ try {
202
+ const exchangeRes = await fetch(`${registryUrl}/api/cli/exchange`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({ code: exchangeCode }),
206
+ })
207
+ if (exchangeRes.ok) {
208
+ const data = await exchangeRes.json()
209
+ token = data.token
210
+ // Feature 44: store refresh token if server provides one
211
+ if (data.refreshToken) {
212
+ refreshToken = data.refreshToken
213
+ }
214
+ } else {
215
+ const err = await exchangeRes.json().catch(() => ({}))
216
+ throw new Error(err.error || "Exchange failed")
217
+ }
218
+ } catch (err) {
219
+ spinner.fail(`Token exchange failed: ${err.message}`)
220
+ res.writeHead(200, { "Content-Type": "text/html" })
221
+ res.end(
222
+ getErrorHtml(
223
+ "Exchange Failed",
224
+ "Failed to exchange the authorization code. Please try again.",
225
+ ),
226
+ )
227
+ closeAndExit(res)
228
+ return
229
+ }
230
+ }
192
231
 
193
232
  if (token) {
194
- // Use async setToken
233
+ // Store access token
195
234
  await setToken(token)
235
+ // Store refresh token for session auth (Feature 44 Part B)
236
+ if (refreshToken) {
237
+ await setRefreshToken(refreshToken)
238
+ }
196
239
 
197
240
  try {
198
241
  const response = await request("/-/whoami")
@@ -535,10 +578,15 @@ export async function login() {
535
578
  }
536
579
  })
537
580
 
581
+ // Compute device fingerprint for session auth (Feature 44 Part B)
582
+ const fp = createHash("sha256")
583
+ .update(`${hostname()}:${userInfo().username}:lpm-cli`)
584
+ .digest("hex")
585
+ const dn = encodeURIComponent(hostname())
586
+
538
587
  server.listen(0, async () => {
539
588
  const port = server.address().port
540
- // Assuming registryUrl is the base URL of the Next.js app
541
- const loginUrl = `${registryUrl}/cli/login?port=${port}`
589
+ const loginUrl = `${registryUrl}/cli/login?port=${port}&fp=${fp}&dn=${dn}`
542
590
 
543
591
  console.log(`Opening browser to: ${loginUrl}`)
544
592
  await open(loginUrl)
@@ -82,8 +82,9 @@ export async function npmrc(options) {
82
82
  : `${registryUrl}/api/registry`
83
83
  const registryHost = fullRegistryUrl.replace(/^https?:/, "")
84
84
 
85
- // Determine proxy vs scoped mode
86
- let useScoped = !!options?.scoped
85
+ // Default: scoped (only @lpm.dev packages through lpm.dev).
86
+ // --proxy: route all npm traffic through lpm.dev (Pro/Org feature).
87
+ let useScoped = !options?.proxy
87
88
 
88
89
  // Read existing .npmrc and check for custom default registry
89
90
  let npmrcContent = ""
@@ -75,8 +75,9 @@ export async function setup(options) {
75
75
  : `${registryUrl}/api/registry`
76
76
  const registryHost = fullRegistryUrl.replace(/^https?:/, "")
77
77
 
78
- // Determine proxy vs scoped mode
79
- let useScoped = !!options?.scoped
78
+ // Default: scoped (only @lpm.dev packages through lpm.dev).
79
+ // --proxy: route all npm traffic through lpm.dev (Pro/Org feature).
80
+ let useScoped = !options?.proxy
80
81
 
81
82
  // ── OIDC mode: exchange CI token for a real short-lived install token ──
82
83
  if (options?.oidc) {
package/lib/config.js CHANGED
@@ -118,6 +118,26 @@ export async function clearToken() {
118
118
  config.delete("token")
119
119
  }
120
120
 
121
+ /**
122
+ * Store a refresh token in secure storage (Feature 44 Part B).
123
+ * Uses the same secure-store as auth tokens with a "refresh:" prefix.
124
+ * @param {string} refreshToken
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function setRefreshToken(refreshToken) {
128
+ const registryUrl = getRegistryUrl()
129
+ await secureSetToken(refreshToken, `refresh:${registryUrl}`)
130
+ }
131
+
132
+ /**
133
+ * Get the stored refresh token from secure storage.
134
+ * @returns {Promise<string|null>}
135
+ */
136
+ export async function getRefreshToken() {
137
+ const registryUrl = getRegistryUrl()
138
+ return secureGetToken(`refresh:${registryUrl}`)
139
+ }
140
+
121
141
  // ============================================================================
122
142
  // Registry URL Configuration
123
143
  // ============================================================================
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@lpm-registry/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.2",
4
4
  "description": "CLI for Licensed Package Manager",
5
5
  "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/lpm-dev/cli"
8
+ "url": "https://github.com/lpm-dev/rust-client"
9
9
  },
10
10
  "homepage": "https://lpm.dev",
11
11
  "license": "ISC",
@@ -61,11 +61,11 @@
61
61
  "xcode": "^3.0.1"
62
62
  },
63
63
  "optionalDependencies": {
64
- "@lpm-registry/cli-darwin-arm64": "0.7.0",
65
- "@lpm-registry/cli-darwin-x64": "0.7.0",
66
- "@lpm-registry/cli-linux-x64": "0.7.0",
67
- "@lpm-registry/cli-linux-arm64": "0.7.0",
68
- "@lpm-registry/cli-win32-x64": "0.7.0",
64
+ "@lpm-registry/cli-darwin-arm64": "0.9.2",
65
+ "@lpm-registry/cli-darwin-x64": "0.9.2",
66
+ "@lpm-registry/cli-linux-x64": "0.9.2",
67
+ "@lpm-registry/cli-linux-arm64": "0.9.2",
68
+ "@lpm-registry/cli-win32-x64": "0.9.2",
69
69
  "keytar": "^7.9.0"
70
70
  },
71
71
  "type": "module",