@gjsify/cli 0.5.0 → 0.5.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.
@@ -78,6 +78,20 @@ export declare function runWatch(finalOpts: BundlerOptions): Promise<RolldownWat
78
78
  */
79
79
  export declare function runBundle(finalOpts: BundlerOptions): Promise<RolldownOutput>;
80
80
  export declare function shouldUseNative(): Promise<boolean>;
81
+ /**
82
+ * Walk parent directories from `startDir` (and optionally a second anchor
83
+ * `bundleDir`) looking for `node_modules/@gjsify/rolldown-native/package.json`.
84
+ * Returns the package root path (parent of its `package.json`) on the first
85
+ * hit, or null when nothing is found within 12 levels.
86
+ *
87
+ * This is a raw `existsSync`-based probe that bypasses both `createRequire`
88
+ * (whose GJS polyfill may not walk the full chain when anchored inside a
89
+ * sub-package dir) and `findWorkspaceRoot` (which requires `discoverWorkspaces`
90
+ * to map the sub-package to a workspace member, a step that can fail under
91
+ * the GJS polyfill stack). Used by `tryLoadNative` as an explicit fallback
92
+ * when the standard `resolveNpmPackage` anchors miss.
93
+ */
94
+ export declare function findRolldownNativeDir(startDir: string, bundleDir?: string): string | null;
81
95
  /**
82
96
  * Sanitize fields the JSON encoder can't ship to the Rust deserializer.
83
97
  *
@@ -27,7 +27,7 @@
27
27
  // at hook-call time. The current gjsify plugin set doesn't use any
28
28
  // of those, so we don't gate on it; future incompatibilities surface
29
29
  // as build errors rather than silent wrong behavior.
30
- import { promises as fs } from 'node:fs';
30
+ import { promises as fs, existsSync } from 'node:fs';
31
31
  import * as path from 'node:path';
32
32
  import { createRequire } from 'node:module';
33
33
  import { pathToFileURL } from 'node:url';
@@ -199,6 +199,39 @@ export async function shouldUseNative() {
199
199
  const native = await tryLoadNative();
200
200
  return native !== null;
201
201
  }
202
+ /**
203
+ * Walk parent directories from `startDir` (and optionally a second anchor
204
+ * `bundleDir`) looking for `node_modules/@gjsify/rolldown-native/package.json`.
205
+ * Returns the package root path (parent of its `package.json`) on the first
206
+ * hit, or null when nothing is found within 12 levels.
207
+ *
208
+ * This is a raw `existsSync`-based probe that bypasses both `createRequire`
209
+ * (whose GJS polyfill may not walk the full chain when anchored inside a
210
+ * sub-package dir) and `findWorkspaceRoot` (which requires `discoverWorkspaces`
211
+ * to map the sub-package to a workspace member, a step that can fail under
212
+ * the GJS polyfill stack). Used by `tryLoadNative` as an explicit fallback
213
+ * when the standard `resolveNpmPackage` anchors miss.
214
+ */
215
+ export function findRolldownNativeDir(startDir, bundleDir) {
216
+ const relPath = path.join('node_modules', '@gjsify', 'rolldown-native', 'package.json');
217
+ const dirsToSearch = [startDir];
218
+ if (bundleDir && bundleDir !== startDir)
219
+ dirsToSearch.push(bundleDir);
220
+ for (const anchor of dirsToSearch) {
221
+ let dir = anchor;
222
+ for (let i = 0; i < 12; i++) {
223
+ const candidate = path.join(dir, relPath);
224
+ if (existsSync(candidate)) {
225
+ return path.join(dir, 'node_modules', '@gjsify', 'rolldown-native');
226
+ }
227
+ const parent = path.resolve(dir, '..');
228
+ if (parent === dir)
229
+ break;
230
+ dir = parent;
231
+ }
232
+ }
233
+ return null;
234
+ }
202
235
  async function tryLoadNative() {
203
236
  if (_nativeProbe)
204
237
  return _nativeProbe;
@@ -229,7 +262,36 @@ async function tryLoadNative() {
229
262
  // install dir, anchoring solely at `import.meta.url`
230
263
  // misses node_modules layouts where the user's cwd
231
264
  // or the workspace root carries the package instead.
232
- const resolved = resolveNpmPackage(specifier, { bundleUrl: import.meta.url }) ??
265
+ //
266
+ // When `resolveNpmPackage`'s standard anchors all miss
267
+ // (e.g. invoked from a package subdir whose own
268
+ // node_modules doesn't carry the optional-peer
269
+ // `@gjsify/rolldown-native`), fall back to a raw
270
+ // `existsSync`-based parent-dir walk — it bypasses
271
+ // both the GJS `createRequire` polyfill's walk
272
+ // limitations and `findWorkspaceRoot`'s dependency on
273
+ // `discoverWorkspaces`, both of which can fail to map a
274
+ // sub-package dir up to the hoisted workspace root.
275
+ const bundleDir = path.dirname(new URL(import.meta.url).pathname);
276
+ const resolvedFromNpm = resolveNpmPackage(specifier, { bundleUrl: import.meta.url });
277
+ const resolvedFromFs = resolvedFromNpm
278
+ ? null
279
+ : (() => {
280
+ const pkgDir = findRolldownNativeDir(process.cwd(), bundleDir);
281
+ if (!pkgDir)
282
+ return null;
283
+ // Resolve the package entry via createRequire anchored
284
+ // inside the found package dir so we get the correct
285
+ // exports-map / main field entry point.
286
+ try {
287
+ return createRequire(pathToFileURL(path.join(pkgDir, '__gjsify_resolve__.js')).href).resolve(specifier);
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ })();
293
+ const resolved = resolvedFromNpm ??
294
+ resolvedFromFs ??
233
295
  createRequire(import.meta.url).resolve(specifier);
234
296
  target = pathToFileURL(resolved).href;
235
297
  }
@@ -35,7 +35,8 @@
35
35
  // in-the-wild publish payloads.
36
36
  import { readFileSync } from 'node:fs';
37
37
  import { join, resolve } from 'node:path';
38
- import { DEFAULT_REGISTRY, registryFor, buildHeaders } from '@gjsify/npm-registry';
38
+ import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
39
+ import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers.js';
39
40
  import { packWorkspace } from './pack.js';
40
41
  import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
41
42
  import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
@@ -208,18 +209,12 @@ export const publishCommand = {
208
209
  const npmrc = await loadNpmrc(wsDir);
209
210
  const registry = process.env.npm_config_registry ?? registryFor(packed.name, npmrc) ?? DEFAULT_REGISTRY;
210
211
  const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
211
- // npm-package-arg's escapedName convention for scoped packages:
212
- // `@${scope-without-leading-@}%2f${name}` (lowercase %2f, literal @).
213
- // Unscoped: `encodeURIComponent(name)`. Match it exactly — the
214
- // npm registry is picky about the publish PUT URL shape.
215
- const escapedName = packed.name.startsWith('@')
216
- ? (() => {
217
- const slash = packed.name.indexOf('/');
218
- const scope = packed.name.slice(1, slash);
219
- const base = packed.name.slice(slash + 1);
220
- return `@${encodeURIComponent(scope)}%2f${encodeURIComponent(base)}`;
221
- })()
222
- : encodeURIComponent(packed.name);
212
+ // npm-package-arg's escapedName convention (npa.js:188):
213
+ // `name.replace('/', '%2f')` — scoped `@scope/name` → `@scope%2fname`
214
+ // (literal @, lowercase %2f, segments NOT otherwise re-encoded);
215
+ // unscoped names pass through. Match it exactly — the npm registry is
216
+ // picky about the publish PUT URL shape.
217
+ const escapedName = escapePackageName(packed.name);
223
218
  const url = `${registryClean}/${escapedName}`;
224
219
  // npm publish convention: the dist.tarball URL + _attachments key both
225
220
  // use the UNSCOPED basename — `cli-0.4.5.tgz`, not
@@ -241,14 +236,15 @@ export const publishCommand = {
241
236
  packed: { ...packed, wireFilename },
242
237
  provenance,
243
238
  });
244
- const headers = buildHeaders(url, { npmrc });
245
- headers['content-type'] = 'application/json';
246
- headers['accept'] = '*/*';
247
- // 2FA OTP — sent as the `npm-otp` header (npm-registry-fetch convention,
248
- // verified in refs/npm-cli/node_modules/npm-registry-fetch/lib/index.js
249
- // line ~243: `if (opts.otp) headers['npm-otp'] = opts.otp`).
250
- if (otp)
251
- headers['npm-otp'] = otp;
239
+ // Build the publish-PUT headers to match what `npm publish` sends via
240
+ // npm-registry-fetch's `getHeaders` — critically `npm-command: publish`
241
+ // (the routing header npm's create-package frontdoor keys on; without it
242
+ // a first-publish of a brand-new scoped package returns a bare 404
243
+ // `Not Found`), plus `npm-auth-type` (legacy when --otp, else web), a
244
+ // real `user-agent`, and `npm-otp` when an OTP is supplied. See
245
+ // `utils/publish-headers.ts` for the field-by-field refs/npm-cli
246
+ // evidence.
247
+ const headers = buildPublishHeaders(url, { npmrc, otp });
252
248
  // Trusted Publishing path. `--trusted` forces OIDC (errors if env
253
249
  // vars are missing); the default `undefined` triggers auto-detect:
254
250
  // OIDC is used iff GitHub OIDC env vars are present AND no
@@ -301,7 +297,8 @@ export const publishCommand = {
301
297
  handleOidcFailure(err, packed.name, args.json === true);
302
298
  process.exit(1);
303
299
  }
304
- // Auto-detect: fall back to whatever buildHeaders found.
300
+ // Auto-detect: fall back to the npmrc token auth that
301
+ // buildPublishHeaders already resolved into `headers`.
305
302
  if (verbose) {
306
303
  const msg = err instanceof Error ? err.message : String(err);
307
304
  console.error(`gjsify publish: OIDC auto-detect failed (${msg}) — falling back to token auth`);
@@ -0,0 +1,45 @@
1
+ import { type NpmrcConfig } from '@gjsify/npm-registry';
2
+ /**
3
+ * Percent-encode a package name for the publish PUT path, matching
4
+ * npm-package-arg's `escapedName` convention (npa.js:188):
5
+ * `this.escapedName = name.replace('/', '%2f')`.
6
+ *
7
+ * Scoped: `@scope/name` → `@scope%2fname` (literal `@`, lowercase `%2f`, the
8
+ * scope/name segments NOT otherwise re-encoded — npm only replaces the slash).
9
+ * Unscoped: returned unchanged (npa stores the bare name; the only characters
10
+ * npm forbids are those `encodeURIComponent` would touch, which npa rejects up
11
+ * front). The npm registry is picky about this exact shape on the publish PUT
12
+ * URL — `%2F` (uppercase) or a re-encoded `@` would miss the create route.
13
+ */
14
+ export declare function escapePackageName(name: string): string;
15
+ export interface PublishHeaderOptions {
16
+ /** Parsed ~/.npmrc — supplies the `authorization` (Bearer/Basic) header. */
17
+ npmrc?: NpmrcConfig;
18
+ /** 2FA one-time code. When present, sent as `npm-otp` AND flips auth-type. */
19
+ otp?: string;
20
+ /**
21
+ * Override the `authorization` header (e.g. an OIDC Bearer token resolved
22
+ * after `buildHeaders` ran). When set it replaces whatever the npmrc auth
23
+ * resolution produced.
24
+ */
25
+ authorization?: string;
26
+ /**
27
+ * npm user-agent. Defaults to an npm-CLI-shaped string. The registry does
28
+ * not validate the exact value, but a real UA is what npm sends; the dead
29
+ * `gjsify-install/0.3.7` placeholder is not used here.
30
+ */
31
+ userAgent?: string;
32
+ }
33
+ /**
34
+ * Build the full set of headers for the publish PUT, mirroring
35
+ * `npm-registry-fetch`'s `getHeaders` for `npm publish`.
36
+ *
37
+ * The set: `authorization`, `content-type: application/json`,
38
+ * `npm-command: publish`, `npm-auth-type` (`web`, or `legacy` when `--otp`),
39
+ * `user-agent`, and `npm-otp` when an OTP is supplied.
40
+ *
41
+ * Notably it does NOT set an `accept: *` wildcard — `npm-registry-fetch` sends
42
+ * no `accept` header on the publish PUT; the stray wildcard gjsify used to send
43
+ * is dropped to match.
44
+ */
45
+ export declare function buildPublishHeaders(url: string, opts: PublishHeaderOptions): Record<string, string>;
@@ -0,0 +1,99 @@
1
+ // Publish-request header builder — matches what npm's CLI sends on a publish
2
+ // PUT so the npm registry's create-package frontdoor routes a *new* scoped
3
+ // package correctly.
4
+ //
5
+ // Root cause this fixes: a first-publish of a never-before-published
6
+ // `@scope/<name>` via token auth was returning a bare `404 Not Found` (body
7
+ // `Not Found`, NOT a JSON npm error). That is a routing miss, not a
8
+ // payload-validation failure — npm's write frontdoor keys the create path on
9
+ // the `npm-command` header, which gjsify was not sending. `npm publish` with
10
+ // the same ~/.npmrc token succeeds because libnpmpublish goes through
11
+ // `npm-registry-fetch`'s `getHeaders`, which always sets `npm-command`
12
+ // (+ `npm-auth-type`, + a real `user-agent`).
13
+ //
14
+ // Evidence (refs/npm-cli):
15
+ // - `lib/npm.js:404` `flat.npmCommand = this.command` → set for EVERY npm
16
+ // command; equals `"publish"` for `npm publish`.
17
+ // - `node_modules/npm-registry-fetch/lib/index.js:231-233`
18
+ // `if (opts.npmCommand) headers['npm-command'] = opts.npmCommand`
19
+ // and `:219-221` `if (opts.authType) headers['npm-auth-type'] = …`
20
+ // and `:215-217` `'user-agent': opts.userAgent`.
21
+ // - `workspaces/config/lib/definitions/definitions.js:282-290` — `auth-type`
22
+ // default `'web'`, flattened to `flatOptions.authType`.
23
+ // - `:1581-1587` — when `--otp` is set, npm forces `auth-type` to `'legacy'`
24
+ // before flattening, so an OTP publish sends `npm-auth-type: legacy`.
25
+ // - `node_modules/npm-package-arg/lib/npa.js:188`
26
+ // `this.escapedName = name.replace('/', '%2f')` — lowercase `%2f`,
27
+ // literal `@`. (gjsify's URL encoding already matches this — it was never
28
+ // the bug.)
29
+ //
30
+ // This module is pure (no I/O, no `gi://`) so it is unit-testable on both Node
31
+ // and GJS without dragging in the runtime fetch/Soup stack.
32
+ import { buildHeaders } from '@gjsify/npm-registry';
33
+ /**
34
+ * Percent-encode a package name for the publish PUT path, matching
35
+ * npm-package-arg's `escapedName` convention (npa.js:188):
36
+ * `this.escapedName = name.replace('/', '%2f')`.
37
+ *
38
+ * Scoped: `@scope/name` → `@scope%2fname` (literal `@`, lowercase `%2f`, the
39
+ * scope/name segments NOT otherwise re-encoded — npm only replaces the slash).
40
+ * Unscoped: returned unchanged (npa stores the bare name; the only characters
41
+ * npm forbids are those `encodeURIComponent` would touch, which npa rejects up
42
+ * front). The npm registry is picky about this exact shape on the publish PUT
43
+ * URL — `%2F` (uppercase) or a re-encoded `@` would miss the create route.
44
+ */
45
+ export function escapePackageName(name) {
46
+ return name.replace('/', '%2f');
47
+ }
48
+ /**
49
+ * Build the full set of headers for the publish PUT, mirroring
50
+ * `npm-registry-fetch`'s `getHeaders` for `npm publish`.
51
+ *
52
+ * The set: `authorization`, `content-type: application/json`,
53
+ * `npm-command: publish`, `npm-auth-type` (`web`, or `legacy` when `--otp`),
54
+ * `user-agent`, and `npm-otp` when an OTP is supplied.
55
+ *
56
+ * Notably it does NOT set an `accept: *` wildcard — `npm-registry-fetch` sends
57
+ * no `accept` header on the publish PUT; the stray wildcard gjsify used to send
58
+ * is dropped to match.
59
+ */
60
+ export function buildPublishHeaders(url, opts) {
61
+ const headers = buildHeaders(url, { npmrc: opts.npmrc });
62
+ // npm-registry-fetch never sets `accept` on the publish PUT — drop the
63
+ // placeholder `accept-encoding` we don't want to influence the create route
64
+ // either. (buildHeaders defaults accept-encoding to `identity`; a publish
65
+ // PUT body is JSON we generated, so the encoding header is irrelevant — but
66
+ // we keep it harmless and explicit by leaving it as buildHeaders set it.)
67
+ // content-type: application/json — npm-registry-fetch sets this for any
68
+ // non-string/non-stream/non-buffer body (index.js:63-67); our body is a
69
+ // JSON string, so we set it explicitly.
70
+ headers['content-type'] = 'application/json';
71
+ // npm-command: publish — THE routing header. npm sets it for every command
72
+ // (npm.js:404); the registry's write frontdoor uses it to route the
73
+ // create-package PUT. Missing it = bare `404 Not Found` on a new package.
74
+ headers['npm-command'] = 'publish';
75
+ // npm-auth-type — `web` by default, `legacy` when an OTP is supplied
76
+ // (definitions.js:1583-1586). Sent by getHeaders (index.js:219-221).
77
+ headers['npm-auth-type'] = opts.otp ? 'legacy' : 'web';
78
+ // user-agent — a real npm-shaped UA (getHeaders index.js:215-217).
79
+ headers['user-agent'] = opts.userAgent ?? defaultUserAgent();
80
+ // npm-otp — only when an OTP is supplied (index.js:243-245).
81
+ if (opts.otp)
82
+ headers['npm-otp'] = opts.otp;
83
+ // authorization override (OIDC token resolved after npmrc auth).
84
+ if (opts.authorization)
85
+ headers['authorization'] = opts.authorization;
86
+ return headers;
87
+ }
88
+ /**
89
+ * npm-CLI-shaped user-agent. Format mirrors npm-registry-fetch's
90
+ * `default-opts.js` (`npm@<v>/node@<v>+<arch> (<platform>)`); we substitute the
91
+ * gjsify CLI identity. The registry does not validate the exact value — it just
92
+ * must be a non-placeholder UA, which some WAF / CDN frontdoors require.
93
+ */
94
+ function defaultUserAgent() {
95
+ const proc = globalThis.process;
96
+ const arch = proc?.arch ?? 'x64';
97
+ const platform = proc?.platform ?? 'linux';
98
+ return `gjsify-publish/0.5.0 (${platform} ${arch})`;
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -120,19 +120,19 @@
120
120
  "cli"
121
121
  ],
122
122
  "dependencies": {
123
- "@gjsify/buffer": "^0.5.0",
124
- "@gjsify/create-app": "^0.5.0",
125
- "@gjsify/node-globals": "^0.5.0",
126
- "@gjsify/node-polyfills": "^0.5.0",
127
- "@gjsify/npm-registry": "^0.5.0",
128
- "@gjsify/resolve-npm": "^0.5.0",
129
- "@gjsify/rolldown-plugin-gjsify": "^0.5.0",
130
- "@gjsify/rolldown-plugin-pnp": "^0.5.0",
131
- "@gjsify/semver": "^0.5.0",
132
- "@gjsify/tar": "^0.5.0",
133
- "@gjsify/tsc": "^0.5.0",
134
- "@gjsify/web-polyfills": "^0.5.0",
135
- "@gjsify/workspace": "^0.5.0",
123
+ "@gjsify/buffer": "^0.5.2",
124
+ "@gjsify/create-app": "^0.5.2",
125
+ "@gjsify/node-globals": "^0.5.2",
126
+ "@gjsify/node-polyfills": "^0.5.2",
127
+ "@gjsify/npm-registry": "^0.5.2",
128
+ "@gjsify/resolve-npm": "^0.5.2",
129
+ "@gjsify/rolldown-plugin-gjsify": "^0.5.2",
130
+ "@gjsify/rolldown-plugin-pnp": "^0.5.2",
131
+ "@gjsify/semver": "^0.5.2",
132
+ "@gjsify/tar": "^0.5.2",
133
+ "@gjsify/tsc": "^0.5.2",
134
+ "@gjsify/web-polyfills": "^0.5.2",
135
+ "@gjsify/workspace": "^0.5.2",
136
136
  "cosmiconfig": "^9.0.2",
137
137
  "get-tsconfig": "^4.14.0",
138
138
  "pkg-types": "^2.3.1",
@@ -140,13 +140,13 @@
140
140
  "yargs": "^18.0.0"
141
141
  },
142
142
  "devDependencies": {
143
- "@gjsify/unit": "^0.5.0",
143
+ "@gjsify/unit": "^0.5.2",
144
144
  "@types/yargs": "^17.0.35",
145
145
  "typescript": "^6.0.3"
146
146
  },
147
147
  "peerDependencies": {
148
- "@gjsify/oxfmt-native": "^0.5.0",
149
- "@gjsify/rolldown-native": "^0.5.0"
148
+ "@gjsify/oxfmt-native": "^0.5.2",
149
+ "@gjsify/rolldown-native": "^0.5.2"
150
150
  },
151
151
  "peerDependenciesMeta": {
152
152
  "@gjsify/oxfmt-native": {