Skip to content

Commit dcae79f

Browse files
hiro-daikinfengmk2
andauthored
fix(create): read org manifest from the tarball when the registry strips createConfig (#2063)
Fixes #2062 (thanks @fengmk2 for the invitation to contribute the fix). ## Problem `vp create @org:name` resolves the org template catalog by fetching the registry packument and reading `versions[x].createConfig`. Some registries — GitHub Packages among them — store only the package.json fields the npm CLI requires, so custom fields like `createConfig` are **absent from packument version metadata** even though the published tarball preserves the full package.json byte-for-byte. (Per the [npm registry spec](https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md), full version objects *should* carry all publisher fields — npmjs.org does — but alternative registries demonstrably don't; the same behavior was hit by Renovate's npm-hosted presets in [renovate#8266](renovatebot/renovate#8266), where GitHub Support confirmed "only the fields required by the NPM CLI are stored".) Result: `No \`createConfig.templates\` manifest in @org/create — \`@org:name\` requires one.` for any org package hosted on such a registry, no matter how it was published. ## Fix When the resolved packument version metadata lacks `createConfig` **entirely** (as opposed to present-but-empty) and advertises `dist.tarball`, download the tarball and read `createConfig` from its `package.json` — the one artifact every registry preserves verbatim. New `readPackageJsonFromTarball` helper in `org-tarball.ts` reuses the existing `downloadTarball` (streaming + 50 MB cap + auth via `fetchNpmResource`), `verifyIntegrity`, `parseTarGzip`, and `normalizeEntryName` (so only the root `package/package.json` matches). Behavior is unchanged everywhere else: - packument carries the field → fast path, no extra request (asserted by a test) - 404s, `requestedVersion` resolution → unchanged - `createConfig` present but `templates: []` → still "no manifest", **no** fallback fetch - malformed manifests (packument *or* tarball) → still `OrgManifestSchemaError` Known trade-off: in the fallback path the tarball can be downloaded twice (once for the manifest read, once later by `ensureOrgPackageExtracted` for bundled entries — which has its own on-disk cache). Kept the diff minimal; happy to thread the bytes into the extraction cache in this PR or a follow-up if you prefer. ## Validation - Unit: 3 new tests in `org-manifest.spec.ts` (fallback success with a real in-test `nanotar.createTarGzip` fixture and matching sha512 integrity; both-sources-missing → null; fast-path fetch-count assertion); full spec 39/39, package unit suite green, `tsgo` clean, `vp fmt --check` clean. - End-to-end: replayed a **real GitHub Packages packument + tarball pair** (captured from a live scoped package that exhibits the stripping) through a local registry stub: the release CLI reproduces the failure; this branch scaffolds successfully, with the tarball's original `sha512` integrity verified. - Snap tests: no diffs from this change (`create-generator-monorepo` timed out in my local environment — its path doesn't involve the org-manifest code; expecting CI to confirm). Two adjacent issues from #2062 are intentionally **not** addressed here to keep the diff focused: the unauthenticated-first fetch not retrying on 404 (some registries 404 unauthenticated metadata), and the error message conflating not-found / auth-gated / field-stripped. Happy to follow up on either. --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent a6aafc3 commit dcae79f

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

packages/cli/src/create/__tests__/org-manifest.spec.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { createHash } from 'node:crypto';
2+
3+
import { createTarGzip } from 'nanotar';
14
import { afterEach, describe, expect, it, vi } from 'vitest';
25

36
import {
@@ -138,6 +141,8 @@ describe('filterManifestForContext', () => {
138141
});
139142
});
140143

144+
const TARBALL_URL = 'https://registry.npmjs.org/@your-org/create/-/create-1.0.0.tgz';
145+
141146
function packument(
142147
vpTemplates: unknown,
143148
extra: Record<string, unknown> = {},
@@ -150,7 +155,7 @@ function packument(
150155
'1.0.0': {
151156
version: '1.0.0',
152157
dist: {
153-
tarball: 'https://registry.npmjs.org/@your-org/create/-/create-1.0.0.tgz',
158+
tarball: TARBALL_URL,
154159
integrity: 'sha512-fake',
155160
},
156161
createConfig: vpTemplates !== undefined ? { templates: vpTemplates } : undefined,
@@ -171,6 +176,37 @@ function mockFetchJson(body: unknown, status = 200): ReturnType<typeof vi.spyOn>
171176
} as unknown as Response);
172177
}
173178

179+
/** Resolve the requested URL from any of `fetch`'s accepted input shapes. */
180+
function requestUrl(input: string | URL | Request): string {
181+
return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
182+
}
183+
184+
/** A real npm-pack-shaped tarball containing only a package.json. */
185+
async function tarballWith(packageJson: unknown): Promise<Uint8Array> {
186+
return await createTarGzip([
187+
{
188+
name: 'package/package.json',
189+
data: new TextEncoder().encode(JSON.stringify(packageJson)),
190+
},
191+
]);
192+
}
193+
194+
/**
195+
* Mock fetch to serve the packument for the registry URL and a real tarball
196+
* `Response` (with a streamable body) for the `.tgz` URL.
197+
*/
198+
function mockFetchPackumentAndTarball(
199+
packumentBody: unknown,
200+
tarBytes: Uint8Array,
201+
): ReturnType<typeof vi.spyOn> {
202+
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
203+
if (requestUrl(input).endsWith('.tgz')) {
204+
return new Response(tarBytes.slice().buffer, { status: 200 });
205+
}
206+
return new Response(JSON.stringify(packumentBody), { status: 200 });
207+
});
208+
}
209+
174210
describe('readOrgManifest', () => {
175211
afterEach(() => {
176212
vi.restoreAllMocks();
@@ -181,8 +217,64 @@ describe('readOrgManifest', () => {
181217
expect(await readOrgManifest('@your-org')).toBeNull();
182218
});
183219

184-
it('returns null when the package has no createConfig.templates field', async () => {
185-
mockFetchJson(packument(undefined));
220+
it('returns null when neither the packument nor the tarball has createConfig.templates', async () => {
221+
// No `createConfig` in the packument triggers the tarball fallback; the
222+
// tarball's package.json lacks the field too, so the result is still null.
223+
const tarBytes = await tarballWith({ name: '@your-org/create', version: '1.0.0' });
224+
const spy = mockFetchPackumentAndTarball(
225+
packument(undefined, { dist: { tarball: TARBALL_URL } }),
226+
tarBytes,
227+
);
228+
expect(await readOrgManifest('@your-org')).toBeNull();
229+
expect(spy).toHaveBeenCalledTimes(2);
230+
});
231+
232+
it('falls back to the tarball package.json when the registry strips createConfig from the packument', async () => {
233+
// GitHub Packages (and potentially other registries) omit custom fields
234+
// from packument version metadata while the published tarball keeps the
235+
// full package.json.
236+
const tarBytes = await tarballWith({
237+
name: '@your-org/create',
238+
version: '1.0.0',
239+
createConfig: {
240+
templates: [{ name: 'web', description: 'Web app', template: './templates/web' }],
241+
},
242+
});
243+
const integrity = `sha512-${createHash('sha512').update(tarBytes).digest('base64')}`;
244+
const spy = mockFetchPackumentAndTarball(
245+
packument(undefined, { dist: { tarball: TARBALL_URL, integrity } }),
246+
tarBytes,
247+
);
248+
const manifest = await readOrgManifest('@your-org');
249+
expect(manifest).not.toBeNull();
250+
expect(manifest?.templates).toEqual([
251+
{ name: 'web', description: 'Web app', template: './templates/web' },
252+
]);
253+
expect(manifest?.tarballUrl).toBe(TARBALL_URL);
254+
expect(spy).toHaveBeenCalledTimes(2);
255+
});
256+
257+
it('does not download the tarball when the packument carries the manifest', async () => {
258+
const spy = mockFetchJson(
259+
packument([{ name: 'web', description: 'Web app', template: './templates/web' }]),
260+
);
261+
expect(await readOrgManifest('@your-org')).not.toBeNull();
262+
expect(spy).toHaveBeenCalledTimes(1);
263+
});
264+
265+
it('treats a tarball probe failure as "no manifest" so passthrough still works', async () => {
266+
// A normal @scope/create package (no manifest anywhere) whose tarball
267+
// cannot be probed — e.g. a download error — must not turn into a hard
268+
// failure; `null` lets the caller fall through to the passthrough path.
269+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
270+
if (requestUrl(input).endsWith('.tgz')) {
271+
throw new Error('network unreachable');
272+
}
273+
return new Response(
274+
JSON.stringify(packument(undefined, { dist: { tarball: TARBALL_URL } })),
275+
{ status: 200 },
276+
);
277+
});
186278
expect(await readOrgManifest('@your-org')).toBeNull();
187279
});
188280

packages/cli/src/create/org-manifest.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from 'node:path';
22

33
import { fetchNpmResource, getNpmRegistry } from '../utils/npm-config.ts';
4+
import { readPackageJsonFromTarball } from './org-tarball.ts';
45

56
/**
67
* A single template entry shared by org manifests (`createConfig.templates`)
@@ -291,6 +292,11 @@ async function fetchPackument(
291292
* - the package does not exist on the registry (404), or
292293
* - the package exists but has no `createConfig.templates` field
293294
*
295+
* When the packument version metadata lacks `createConfig` entirely, the
296+
* published tarball's package.json is consulted before giving up — some
297+
* registries (GitHub Packages among them) strip custom fields from version
298+
* metadata while preserving the tarball byte-for-byte.
299+
*
294300
* Throws when:
295301
* - the `createConfig.templates` field is present but malformed (`OrgManifestSchemaError`), or
296302
* - the registry request fails for any non-404 reason
@@ -331,7 +337,22 @@ export async function readOrgManifest(
331337
if (!meta) {
332338
return null;
333339
}
334-
const templates = validateManifest(meta, packageName);
340+
let templates = validateManifest(meta, packageName);
341+
if (!templates && meta.createConfig === undefined && meta.dist?.tarball) {
342+
// `createConfig` absent (not merely empty) means the registry either
343+
// stripped it from the version metadata or the package has no manifest;
344+
// probe the published tarball to tell the two apart (see
345+
// `readPackageJsonFromTarball`). The probe is best-effort: any
346+
// download/integrity/parse failure degrades to "no manifest" so a normal
347+
// `@scope/create` package still reaches the passthrough path. A manifest
348+
// that IS present but malformed still throws, because `validateManifest`
349+
// runs outside the catch.
350+
const packageJson = await readPackageJsonFromTarball(
351+
meta.dist.tarball,
352+
meta.dist.integrity,
353+
).catch(() => null);
354+
templates = validateManifest(packageJson, packageName);
355+
}
335356
if (!templates) {
336357
return null;
337358
}

packages/cli/src/create/org-tarball.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ async function downloadTarball(url: string): Promise<Uint8Array> {
116116
return bytes;
117117
}
118118

119+
/**
120+
* Download a package tarball and parse its `package/package.json`.
121+
*
122+
* Some registries (GitHub Packages among them) strip fields they don't
123+
* recognize — including `createConfig` — from packument *version metadata*
124+
* while preserving the published tarball byte-for-byte. This gives
125+
* `readOrgManifest` a fallback source of truth for those registries.
126+
*
127+
* Returns `null` when the archive contains no `package/package.json`.
128+
* Throws on download/integrity failures or unparsable JSON.
129+
*/
130+
export async function readPackageJsonFromTarball(
131+
tarballUrl: string,
132+
integrity?: string,
133+
): Promise<unknown> {
134+
const bytes = await downloadTarball(tarballUrl);
135+
verifyIntegrity(bytes, integrity);
136+
const entries = await parseTarGzip(bytes);
137+
for (const entry of entries) {
138+
if (normalizeEntryName(entry.name) !== 'package.json' || !entry.data) {
139+
continue;
140+
}
141+
const text = new TextDecoder().decode(entry.data);
142+
try {
143+
return JSON.parse(text) as unknown;
144+
} catch {
145+
throw new Error(`invalid package.json in tarball: ${tarballUrl}`);
146+
}
147+
}
148+
return null;
149+
}
150+
119151
const STAGING_SUFFIX_PREFIX = '.tmp-';
120152

121153
/**

0 commit comments

Comments
 (0)