@vzn/vx-migrate 0.0.645 → 0.0.648

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.
@@ -48,13 +48,11 @@ export function oidcSettings(
48
48
  ): Omit<OidcConfig, 'idTokenUrl' | 'idTokenBearer'> {
49
49
  const o = oidc === true ? {} : oidc
50
50
  if (typeof o !== 'object' || o === null)
51
- throw new Error(
52
- 'vx/turbo-cache: oidc must be true or { tokenUrl, revokeUrl, audience, params }',
53
- )
51
+ throw new Error('turboCache(): oidc must be true or { tokenUrl, revokeUrl, audience, params }')
54
52
  if (!vercel && o.tokenUrl === undefined)
55
- throw new Error("vx/turbo-cache: oidc needs tokenUrl for a cache that is not Vercel's")
53
+ throw new Error("turboCache(): oidc needs tokenUrl for a cache that is not Vercel's")
56
54
  if (vercel && team === undefined)
57
- throw new Error('vx/turbo-cache: oidc on Vercel needs teamId or teamSlug (TURBO_TEAM)')
55
+ throw new Error('turboCache(): oidc on Vercel needs teamId or teamSlug (TURBO_TEAM)')
58
56
  const revokeUrl = o.revokeUrl ?? (vercel ? VERCEL_REVOKE_URL : undefined)
59
57
  return {
60
58
  tokenUrl: o.tokenUrl ?? VERCEL_TOKEN_URL,
@@ -101,22 +99,44 @@ const form = (fields: Record<string, string>) => ({
101
99
  /** Where a URL points, never its query: GitHub's carries a runtime id. */
102
100
  const where = (url: string) => (URL.canParse(url) ? new URL(url).host : 'the endpoint')
103
101
 
102
+ /**
103
+ * A refusal's own words: RFC 6749's `error` and `error_description`, or
104
+ * GitHub's `message`. Only those fields, which name a fault and never
105
+ * carry a token; any other body is left unread. "answered HTTP 400" alone
106
+ * did not say the audience or the grant was wrong.
107
+ */
108
+ async function refused(res: Response, what: string): Promise<Error> {
109
+ // quiet: a body that is not JSON says nothing; the status still names the refusal
110
+ const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
111
+ const text = (v: unknown): string | undefined =>
112
+ typeof v === 'string' && v.trim() !== ''
113
+ ? v.replace(/\s+/g, ' ').trim().slice(0, 200)
114
+ : undefined
115
+ const said = [text(body?.['error']), text(body?.['error_description']) ?? text(body?.['message'])]
116
+ .filter((s) => s !== undefined)
117
+ .join(': ')
118
+ return new Error(`${what} answered HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
119
+ }
120
+
104
121
  async function field(res: Response, name: string, what: string): Promise<string> {
105
- if (!res.ok) {
106
- await res.body?.cancel()
107
- throw new Error(`${what} answered HTTP ${res.status}`)
108
- }
122
+ if (!res.ok) throw await refused(res, what)
109
123
  const value = ((await res.json().catch(() => null)) as Record<string, unknown> | null)?.[name]
110
124
  if (typeof value !== 'string' || value === '') throw new Error(`${what} sent no ${name}`)
111
125
  return value
112
126
  }
113
127
 
128
+ /** An exchanged token, and when it ends (epoch ms) when the server says. */
129
+ export interface Exchanged {
130
+ readonly token: string
131
+ readonly expiresAt?: number
132
+ }
133
+
114
134
  /** The job's OIDC token, exchanged for the cache's access token. Errors never quote a token. */
115
135
  export async function exchangeOidc(
116
136
  cfg: OidcConfig,
117
137
  fetchImpl: typeof fetch = fetch,
118
138
  wait: (ms: number) => Promise<void> = Bun.sleep,
119
- ): Promise<string> {
139
+ ): Promise<Exchanged> {
120
140
  const idUrl = new URL(cfg.idTokenUrl)
121
141
  if (cfg.audience !== undefined) idUrl.searchParams.set('audience', cfg.audience)
122
142
  const idToken = await field(
@@ -129,22 +149,28 @@ export async function exchangeOidc(
129
149
  'value',
130
150
  `GitHub's OIDC endpoint (${where(cfg.idTokenUrl)})`,
131
151
  )
132
- return field(
133
- await send(
134
- fetchImpl,
135
- cfg.tokenUrl,
136
- form({
137
- grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
138
- subject_token: idToken,
139
- subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
140
- requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
141
- ...cfg.params,
142
- }),
143
- wait,
144
- ),
145
- 'access_token',
146
- `the token exchange (${where(cfg.tokenUrl)})`,
152
+ const res = await send(
153
+ fetchImpl,
154
+ cfg.tokenUrl,
155
+ form({
156
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
157
+ subject_token: idToken,
158
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
159
+ requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
160
+ ...cfg.params,
161
+ }),
162
+ wait,
147
163
  )
164
+ const what = `the token exchange (${where(cfg.tokenUrl)})`
165
+ if (!res.ok) throw await refused(res, what)
166
+ const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
167
+ const token = body?.['access_token']
168
+ if (typeof token !== 'string' || token === '') throw new Error(`${what} sent no access_token`)
169
+ const ttl = body?.['expires_in']
170
+ return {
171
+ token,
172
+ ...(typeof ttl === 'number' && ttl > 0 ? { expiresAt: Date.now() + ttl * 1000 } : {}),
173
+ }
148
174
  }
149
175
 
150
176
  /** Revoke the exchanged token; a throw is the caller's warning. */
@@ -166,6 +192,6 @@ export async function revokeOidc(
166
192
  }),
167
193
  wait,
168
194
  )
195
+ if (!res.ok) throw await refused(res, `revoking at ${where(cfg.revokeUrl)}`)
169
196
  await res.body?.cancel()
170
- if (!res.ok) throw new Error(`revoking at ${where(cfg.revokeUrl)} answered HTTP ${res.status}`)
171
197
  }
@@ -1,4 +1,4 @@
1
- import type { ArtifactInfo, RemoteCacheLayer } from '@vzn/vx'
1
+ import { type ArtifactInfo, type RemoteCacheLayer, UserError } from '@vzn/vx'
2
2
 
3
3
  /**
4
4
  * How a remote keeps branches apart (2026-10-20). `'header'`: plain keys,
@@ -10,9 +10,10 @@ import type { ArtifactInfo, RemoteCacheLayer } from '@vzn/vx'
10
10
  */
11
11
  export type RemoteScope = 'header' | 'key'
12
12
 
13
- export function remoteScope(name: string, scope: unknown): RemoteScope {
13
+ /** `factory`: the option's owner as a workspace calls it, `turboCache`. */
14
+ export function remoteScope(factory: string, scope: unknown): RemoteScope {
14
15
  if (scope === undefined || scope === 'header' || scope === 'key') return scope ?? 'header'
15
- throw new Error(`vx/${name}: scope must be 'header' or 'key', got ${JSON.stringify(scope)}`)
16
+ throw new UserError(`${factory}(): scope must be 'header' or 'key', got ${JSON.stringify(scope)}`)
16
17
  }
17
18
 
18
19
  /** A server caps the read header at this many refs (the server rules), so vx never sends more. */
@@ -57,6 +58,7 @@ export function keyedByRef(inner: RemoteCacheLayer, allRefs: readonly string[]):
57
58
  return {
58
59
  ...(inner.endpoint !== undefined ? { endpoint: inner.endpoint } : {}),
59
60
  ...(inner.open !== undefined ? { open: (ctx) => inner.open!(ctx) } : {}),
61
+ ...(inner.lend !== undefined ? { lend: () => inner.lend!() } : {}),
60
62
  has: firstHeld,
61
63
  ...(hasMany !== undefined
62
64
  ? {
@@ -9,7 +9,8 @@ import { existsSync } from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import { type GeneratedProject, type GeneratedTask, UserError } from '@vzn/vx'
11
11
  import type { PackageMeta } from '../packages.js'
12
- import { minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
12
+ import { refuseTaskNames } from '../plugin-gaps.js'
13
+ import { hasGluedGlobstar, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
13
14
  import { packageScripts, relPosix } from '../paths.js'
14
15
  import { scriptCommand, yarnPnp } from '../script-command.js'
15
16
  import {
@@ -70,9 +71,11 @@ function viteConfigFile(dir: string): string | null {
70
71
 
71
72
  // Bun's resolver reads `\` as a separator, even in a file: URL, so a config
72
73
  // under `a\b` was looked for under `a/b`. Resolved here, the path stays as
73
- // written; the file is read from it.
74
- const LITERAL_QUERY = /\?vx-literal$/
74
+ // written; the file is read from it, found by its number: Bun 1.4.4 hands
75
+ // onLoad the path with `\` made `/`.
76
+ const LITERAL_QUERY = /\?vx-literal=(\d+)$/
75
77
  let servingLiteral = false
78
+ const literals: string[] = []
76
79
 
77
80
  function importAsWritten(file: string): Promise<{ default?: unknown }> {
78
81
  if (!file.includes('\\')) return import(file)
@@ -86,7 +89,7 @@ function importAsWritten(file: string): Promise<{ default?: unknown }> {
86
89
  namespace: 'file',
87
90
  }))
88
91
  build.onLoad({ filter: LITERAL_QUERY }, async (args) => {
89
- const at = args.path.replace(LITERAL_QUERY, '')
92
+ const at = literals[Number(LITERAL_QUERY.exec(args.path)![1])]!
90
93
  return {
91
94
  contents: await Bun.file(at).text(),
92
95
  loader: /\.[cm]?ts$/.test(at) ? 'ts' : 'js',
@@ -95,7 +98,7 @@ function importAsWritten(file: string): Promise<{ default?: unknown }> {
95
98
  },
96
99
  })
97
100
  }
98
- return import(`${file}?vx-literal`)
101
+ return import(`${file}?vx-literal=${literals.push(file) - 1}`)
99
102
  }
100
103
 
101
104
  /**
@@ -384,6 +387,7 @@ export async function mapViteTaskWorkspace(
384
387
  `workspace dependencies spell (${n} task${n === 1 ? '' : 's'}) — add any other a task reads to ` +
385
388
  'inputs.env and exec.env.passThrough',
386
389
  )
390
+ refuseTaskNames(projects, 'task')
387
391
  return { projects, notes }
388
392
  }
389
393
 
@@ -551,7 +555,8 @@ function globs(
551
555
  } else continue
552
556
  const neg = pattern.startsWith('!')
553
557
  const raw = neg ? pattern.slice(1) : pattern
554
- const wax = minimatchToVx(raw, neg)
558
+ // wax refuses a glued `**` (`src/**.ts`), and so does core.
559
+ const wax = hasGluedGlobstar(raw) ? null : minimatchToVx(raw, neg)
555
560
  if (wax === null) {
556
561
  todos.push(
557
562
  `${kind} ${JSON.stringify(pattern)}: glob syntax vx cannot take — ` +
@@ -1,5 +1,5 @@
1
1
  // The plugins a migrated workspace declares, from what the repo shows
2
- // (owner, 2026-10-06: "install and migrate fully … use the plugins"). Each
2
+ // (2026-10-06: "install and migrate fully … use the plugins"). Each
3
3
  // is inert where it does not apply, so declaring one costs a run nothing,
4
4
  // and each is installed beside vx at vx-migrate's own version (plugins
5
5
  // pin their core peer to the release they shipped with).
@@ -23,9 +23,12 @@ export function workspacePlugins(root: string, runner?: 'turbo' | 'nx'): Workspa
23
23
  out.push({ pkg: '@vzn/vx-migrate', factory: REMOTE_CACHE[runner] })
24
24
  const lock = lockfilePlugin(root)
25
25
  if (lock !== undefined) out.push({ pkg: '@vzn/vx-lockfile', factory: lock.factory })
26
- out.push({ pkg: '@vzn/vx-schedule-history', factory: 'scheduleHistoryPlugin' })
26
+ out.push({ pkg: '@vzn/vx-history', factory: 'vxHistory' })
27
27
  if (existsSync(path.join(root, '.github', 'workflows')))
28
28
  out.push({ pkg: '@vzn/vx-ci', factory: 'github' })
29
+ // A remote cache on CI reads the refs only these name (2026-10-25).
30
+ if (existsSync(path.join(root, '.gitlab-ci.yml')))
31
+ out.push({ pkg: '@vzn/vx-ci', factory: 'gitlab' })
29
32
  return out
30
33
  }
31
34