@gjsify/cli 0.4.4 → 0.4.9

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.
@@ -0,0 +1,319 @@
1
+ // `gjsify publish [path] [--tag <tag>] [--access <a>] [--tolerate-republish] [--dry-run]`
2
+ //
3
+ // Packs the workspace via `packWorkspace()` (Phase E.1), then PUTs the
4
+ // tarball to the configured npm registry. Mirrors `npm publish`'s observable
5
+ // behavior:
6
+ //
7
+ // - Reads .npmrc for registry URL + auth (project-local + ~/.npmrc), with
8
+ // `npm_config_registry` env override.
9
+ // - Scoped packages route to the scope's registry if configured
10
+ // (`@scope:registry=...` in .npmrc).
11
+ // - Auth: bearer token (`_authToken`) or basic (`_auth`).
12
+ // - --tolerate-republish: surface a 409 conflict as a notice + exit 0
13
+ // (matches yarn's flag of the same name).
14
+ // - --tag: published version gets this dist-tag (default `latest`).
15
+ // - --access: public | restricted — required for first publish of a
16
+ // scoped package on the public registry.
17
+ // - --provenance: pass-through only — actual provenance generation requires
18
+ // a sigstore signer that gjsify doesn't ship; we record the flag in the
19
+ // publish manifest but don't sign yet. Surface a warning so callers know.
20
+ // - --dry-run: pack only, no PUT.
21
+ //
22
+ // The request body matches npm's "publish" payload shape:
23
+ // {
24
+ // "_id": "<name>",
25
+ // "name": "<name>",
26
+ // "description": "<from package.json>",
27
+ // "dist-tags": { "<tag>": "<version>" },
28
+ // "versions": { "<version>": { ...full package.json + dist } },
29
+ // "_attachments": { "<filename>": { content_type, data: base64, length } }
30
+ // }
31
+ //
32
+ // Source: documented in https://docs.npmjs.com/cli/v10/commands/npm-publish
33
+ // and npm's @npmcli/registry-fetch internals — verified against npm's
34
+ // in-the-wild publish payloads.
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { homedir } from 'node:os';
37
+ import { join, resolve } from 'node:path';
38
+ import { DEFAULT_REGISTRY, parseNpmrc, registryFor, buildHeaders, } from '@gjsify/npm-registry';
39
+ import { packWorkspace } from './pack.js';
40
+ export const publishCommand = {
41
+ command: 'publish [path]',
42
+ description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
43
+ builder: (yargs) => yargs
44
+ .positional('path', { description: 'Workspace path (default: cwd).', type: 'string' })
45
+ .option('tag', {
46
+ description: 'Dist-tag to publish under. Default: latest.',
47
+ type: 'string',
48
+ default: 'latest',
49
+ })
50
+ .option('access', {
51
+ description: 'Package access — `public` or `restricted` (required for first publish of scoped packages on the public registry).',
52
+ type: 'string',
53
+ })
54
+ .option('tolerate-republish', {
55
+ description: 'Treat a 409 conflict (version already published) as success. Matches yarn `--tolerate-republish`.',
56
+ type: 'boolean',
57
+ default: false,
58
+ })
59
+ .option('provenance', {
60
+ description: 'Pass-through flag — recorded in the payload but no signing happens (gjsify doesn\'t ship a sigstore signer yet).',
61
+ type: 'boolean',
62
+ default: false,
63
+ })
64
+ .option('dry-run', {
65
+ description: 'Pack only, do not PUT.',
66
+ type: 'boolean',
67
+ default: false,
68
+ })
69
+ .option('json', {
70
+ description: 'Emit publish metadata as JSON on stdout.',
71
+ type: 'boolean',
72
+ default: false,
73
+ }),
74
+ handler: async (args) => {
75
+ const wsDir = resolve(args.path ?? process.cwd());
76
+ const tag = args.tag ?? 'latest';
77
+ const access = args.access;
78
+ const tolerate = args['tolerate-republish'] === true;
79
+ const provenance = args.provenance === true;
80
+ const dryRun = args['dry-run'] === true;
81
+ if (provenance) {
82
+ console.warn('gjsify publish: --provenance recorded but not signed (no sigstore integration yet).');
83
+ }
84
+ // 1. Pack the workspace (rewrites workspace:^, computes integrity)
85
+ const packOpts = { dryRun: true };
86
+ const packed = await packWorkspace(wsDir, packOpts);
87
+ // We need the raw bytes — re-run with destination=null and capture.
88
+ // packWorkspace returns metadata only; for the bytes we re-pack into
89
+ // memory by reading + rebuilding. Cheap because the second pack runs
90
+ // off the same source files. (A future optimization: have
91
+ // packWorkspace optionally return the bytes itself.)
92
+ const tarBytes = await packWorkspaceToBytes(wsDir);
93
+ // 2. Read the workspace's (rewritten) package.json to assemble the
94
+ // publish payload.
95
+ const pkgPath = join(wsDir, 'package.json');
96
+ const pkgSource = readFileSync(pkgPath, 'utf-8');
97
+ const pkg = JSON.parse(pkgSource);
98
+ // We need the rewritten manifest (workspace:^ → resolved) for the
99
+ // payload — packWorkspace already wrote it into the tarball. Mirror
100
+ // the same rewrite here so the registry's "package metadata" matches
101
+ // the tarball's package.json byte-for-byte.
102
+ const rewrittenPkg = await loadRewrittenManifest(wsDir, pkg);
103
+ if (dryRun) {
104
+ const message = {
105
+ ok: true,
106
+ action: 'dry-run',
107
+ name: packed.name,
108
+ version: packed.version,
109
+ filename: packed.filename,
110
+ size: packed.size,
111
+ shasum: packed.shasum,
112
+ integrity: packed.integrity,
113
+ };
114
+ if (args.json)
115
+ process.stdout.write(`${JSON.stringify(message, null, 2)}\n`);
116
+ else
117
+ process.stdout.write(`+ ${packed.name}@${packed.version} (dry-run, ${packed.size} bytes, ${packed.entryCount} files)\n`);
118
+ return;
119
+ }
120
+ // 3. Resolve registry URL + auth
121
+ const npmrc = await loadNpmrc(wsDir);
122
+ const registry = process.env.npm_config_registry ?? registryFor(packed.name, npmrc) ?? DEFAULT_REGISTRY;
123
+ const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
124
+ // npm-package-arg's escapedName convention for scoped packages:
125
+ // `@${scope-without-leading-@}%2f${name}` (lowercase %2f, literal @).
126
+ // Unscoped: `encodeURIComponent(name)`. Match it exactly — the
127
+ // npm registry is picky about the publish PUT URL shape.
128
+ const escapedName = packed.name.startsWith('@')
129
+ ? (() => {
130
+ const slash = packed.name.indexOf('/');
131
+ const scope = packed.name.slice(1, slash);
132
+ const base = packed.name.slice(slash + 1);
133
+ return `@${encodeURIComponent(scope)}%2f${encodeURIComponent(base)}`;
134
+ })()
135
+ : encodeURIComponent(packed.name);
136
+ const url = `${registryClean}/${escapedName}`;
137
+ // npm publish convention: the dist.tarball URL + _attachments key both
138
+ // use the UNSCOPED basename — `cli-0.4.5.tgz`, not
139
+ // `gjsify-cli-0.4.5.tgz`. This is what libnpmpublish does (see
140
+ // `attachments[\`${unscopedName}-${version}.tgz\`]` in
141
+ // libnpmpublish/lib/publish.js). The full scoped filename is what
142
+ // `npm pack` writes to disk, but the registry stores tarballs at
143
+ // the unscoped path.
144
+ const unscopedName = packed.name.includes('/')
145
+ ? packed.name.slice(packed.name.indexOf('/') + 1)
146
+ : packed.name;
147
+ const wireFilename = `${unscopedName}-${packed.version}.tgz`;
148
+ const tarballUrl = `${registryClean}/${packed.name}/-/${wireFilename}`;
149
+ // 4. Build payload + PUT
150
+ const payload = buildPublishPayload({
151
+ pkg: rewrittenPkg,
152
+ tag,
153
+ access,
154
+ tarballBytes: tarBytes,
155
+ tarballUrl,
156
+ packed: { ...packed, wireFilename },
157
+ provenance,
158
+ });
159
+ const headers = buildHeaders(url, { npmrc });
160
+ headers['content-type'] = 'application/json';
161
+ headers['accept'] = '*/*';
162
+ if (process.env.GJSIFY_PUBLISH_DEBUG) {
163
+ console.error(`gjsify publish: PUT ${url} (${packed.name}@${packed.version})`);
164
+ console.error(` authorization: ${headers['authorization'] ? '(set)' : '(none)'}`);
165
+ console.error(` payload size: ${JSON.stringify(payload).length} bytes`);
166
+ }
167
+ const res = await fetch(url, {
168
+ method: 'PUT',
169
+ headers,
170
+ body: JSON.stringify(payload),
171
+ });
172
+ if (res.ok) {
173
+ const out = {
174
+ ok: true,
175
+ name: packed.name,
176
+ version: packed.version,
177
+ filename: packed.filename,
178
+ size: packed.size,
179
+ integrity: packed.integrity,
180
+ tag,
181
+ registry: registryClean,
182
+ };
183
+ if (args.json)
184
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
185
+ else
186
+ process.stdout.write(`+ ${packed.name}@${packed.version}\n`);
187
+ return;
188
+ }
189
+ const text = await res.text().catch(() => '<no body>');
190
+ if (res.status === 409 && tolerate) {
191
+ const out = {
192
+ ok: true,
193
+ action: 'republish-tolerated',
194
+ name: packed.name,
195
+ version: packed.version,
196
+ status: 409,
197
+ };
198
+ if (args.json)
199
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
200
+ else
201
+ process.stdout.write(`= ${packed.name}@${packed.version} (already published, tolerated)\n`);
202
+ return;
203
+ }
204
+ console.error(`gjsify publish: ${packed.name}@${packed.version} — ${res.status} ${res.statusText}`);
205
+ console.error(text);
206
+ process.exit(1);
207
+ },
208
+ };
209
+ async function packWorkspaceToBytes(wsDir) {
210
+ // Cheap re-run that writes to a tempdir, then read back. Avoids
211
+ // duplicating the file-walking + tar-building logic here.
212
+ const tmp = `/tmp/gjsify-publish-${process.pid}-${Date.now()}`;
213
+ const res = await packWorkspace(wsDir, { destination: tmp, dryRun: false });
214
+ if (!res.absolutePath)
215
+ throw new Error('gjsify publish: pack did not produce a file');
216
+ const bytes = new Uint8Array(readFileSync(res.absolutePath));
217
+ try {
218
+ (await import('node:fs')).rmSync(res.absolutePath);
219
+ }
220
+ catch { /* best effort */ }
221
+ try {
222
+ (await import('node:fs')).rmdirSync(tmp);
223
+ }
224
+ catch { /* best effort */ }
225
+ return bytes;
226
+ }
227
+ async function loadRewrittenManifest(wsDir, pkg) {
228
+ // Pack + re-read the tarball's package.json. Easier than duplicating the
229
+ // rewrite logic — pack already does it correctly, including handling
230
+ // workspace:^ patterns we'd otherwise have to reimplement here.
231
+ const tmp = `/tmp/gjsify-publish-manifest-${process.pid}-${Date.now()}.tgz`;
232
+ const res = await packWorkspace(wsDir, {
233
+ destination: tmp.substring(0, tmp.lastIndexOf('/')),
234
+ dryRun: false,
235
+ });
236
+ const { rmSync } = await import('node:fs');
237
+ if (!res.absolutePath)
238
+ throw new Error('gjsify publish: pack did not produce a file');
239
+ const { gunzip, parseTar } = await import('@gjsify/tar');
240
+ const bytes = new Uint8Array(readFileSync(res.absolutePath));
241
+ rmSync(res.absolutePath);
242
+ const tar = await gunzip(bytes);
243
+ for (const entry of parseTar(tar)) {
244
+ if (entry.name === 'package/package.json' && entry.body) {
245
+ return JSON.parse(new TextDecoder().decode(entry.body));
246
+ }
247
+ }
248
+ return pkg;
249
+ }
250
+ async function loadNpmrc(cwd) {
251
+ // npm CLI's npmrc resolution order (lowest → highest precedence):
252
+ // 1. globalconfig: /etc/npmrc (system)
253
+ // 2. userconfig: $NPM_CONFIG_USERCONFIG (overrides ~/.npmrc)
254
+ // or ~/.npmrc (default)
255
+ // 3. projectconfig: ./.npmrc (closest)
256
+ //
257
+ // actions/setup-node writes the auth-token npmrc to $RUNNER_TEMP/.npmrc
258
+ // and exports NPM_CONFIG_USERCONFIG pointing at it — it does NOT touch
259
+ // ~/.npmrc. Honor the env var so CI authentication works end-to-end.
260
+ const sources = [];
261
+ const projectNpmrc = join(cwd, '.npmrc');
262
+ if (existsSync(projectNpmrc))
263
+ sources.push(readFileSync(projectNpmrc, 'utf-8'));
264
+ const userConfig = process.env.NPM_CONFIG_USERCONFIG;
265
+ if (userConfig && existsSync(userConfig)) {
266
+ sources.push(readFileSync(userConfig, 'utf-8'));
267
+ }
268
+ else {
269
+ const homeNpmrc = join(homedir(), '.npmrc');
270
+ if (existsSync(homeNpmrc))
271
+ sources.push(readFileSync(homeNpmrc, 'utf-8'));
272
+ }
273
+ // Inline `${VAR}` placeholders (npm CLI's expand-on-read behavior).
274
+ // The auth-token npmrc from actions/setup-node ships
275
+ // `_authToken=${NODE_AUTH_TOKEN}` as a literal placeholder; the env var
276
+ // is set on the publish step.
277
+ const merged = sources.join('\n').replace(/\$\{([A-Z_][A-Z0-9_]*)\}/gi, (_, name) => process.env[name] ?? '');
278
+ return parseNpmrc(merged);
279
+ }
280
+ function buildPublishPayload(opts) {
281
+ const { pkg, tag, access, tarballBytes, tarballUrl, packed, provenance } = opts;
282
+ const versionEntry = {
283
+ ...pkg,
284
+ _id: `${packed.name}@${packed.version}`,
285
+ dist: {
286
+ integrity: packed.integrity,
287
+ shasum: packed.shasum,
288
+ tarball: tarballUrl,
289
+ },
290
+ };
291
+ if (provenance)
292
+ versionEntry._hasShrinkwrap = false;
293
+ const payload = {
294
+ _id: packed.name,
295
+ name: packed.name,
296
+ description: typeof pkg.description === 'string' ? pkg.description : '',
297
+ 'dist-tags': { [tag]: packed.version },
298
+ versions: { [packed.version]: versionEntry },
299
+ readme: '',
300
+ _attachments: {
301
+ [packed.wireFilename]: {
302
+ content_type: 'application/octet-stream',
303
+ data: base64Encode(tarballBytes),
304
+ length: tarballBytes.byteLength,
305
+ },
306
+ },
307
+ };
308
+ if (access)
309
+ payload.access = access;
310
+ return payload;
311
+ }
312
+ function base64Encode(bytes) {
313
+ let str = '';
314
+ const chunk = 0x8000;
315
+ for (let i = 0; i < bytes.length; i += chunk) {
316
+ str += String.fromCharCode(...bytes.subarray(i, i + chunk));
317
+ }
318
+ return btoa(str);
319
+ }
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from 'yargs';
3
3
  import { hideBin } from 'yargs/helpers';
4
- import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, } from './commands/index.js';
4
+ import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, } from './commands/index.js';
5
5
  import { APP_NAME } from './constants.js';
6
6
  // `parseAsync()` instead of `.argv` so the top-level await keeps the
7
7
  // process alive until command handlers complete. Under Node this is
@@ -25,6 +25,8 @@ await yargs(hideBin(process.argv))
25
25
  .command(flatpak.command, flatpak.description, flatpak.builder, flatpak.handler)
26
26
  .command(foreach.command, foreach.description, foreach.builder, foreach.handler)
27
27
  .command(workspace.command, workspace.description, workspace.builder, workspace.handler)
28
+ .command(pack.command, pack.description, pack.builder, pack.handler)
29
+ .command(publish.command, publish.description, publish.builder, publish.handler)
28
30
  .demandCommand(1)
29
31
  .help()
30
32
  .parseAsync();
@@ -56,7 +56,7 @@ export async function installPackagesNative(opts) {
56
56
  }
57
57
  else {
58
58
  log("install: resolving %d top-level spec(s) → %s", opts.specs.length, opts.prefix);
59
- nodes = await resolveDeps(opts.specs, npmrc, log);
59
+ nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides);
60
60
  if (opts.lockfile) {
61
61
  writeLockfile(lockfilePath, opts.specs, nodes);
62
62
  log("install: wrote %s (%d entries)", LOCKFILE_NAME, nodes.length);
@@ -114,7 +114,18 @@ function parseSpecName(spec) {
114
114
  * the root. Each placement returns a `ResolvedNode` whose `installPath`
115
115
  * captures where it lives in the tree.
116
116
  */
117
- async function resolveDeps(specs, npmrc, log) {
117
+ async function resolveDeps(specs, npmrc, log, overrides) {
118
+ const applyOverride = (name, range) => {
119
+ if (!overrides)
120
+ return range;
121
+ const override = overrides[name];
122
+ if (typeof override !== 'string' || override.length === 0)
123
+ return range;
124
+ if (override === range)
125
+ return range;
126
+ log("install: override %s %s → %s", name, range, override);
127
+ return override;
128
+ };
118
129
  const packumentCache = new Map();
119
130
  const fetchPkg = (name) => {
120
131
  const cached = packumentCache.get(name);
@@ -131,7 +142,7 @@ async function resolveDeps(specs, npmrc, log) {
131
142
  const queue = specs.map(parseSpec).map((s) => ({
132
143
  from: null,
133
144
  name: s.name,
134
- range: s.range,
145
+ range: applyOverride(s.name, s.range),
135
146
  required: true,
136
147
  }));
137
148
  while (queue.length > 0) {
@@ -181,10 +192,10 @@ async function resolveDeps(specs, npmrc, log) {
181
192
  }
182
193
  log("resolve: %s@%s ← %s (at %s)", edge.name, version, edge.range, installPath);
183
194
  for (const [depName, depRange] of Object.entries(node.dependencies)) {
184
- queue.push({ from: installPath, name: depName, range: depRange, required: true });
195
+ queue.push({ from: installPath, name: depName, range: applyOverride(depName, depRange), required: true });
185
196
  }
186
197
  for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
187
- queue.push({ from: installPath, name: depName, range: depRange, required: false });
198
+ queue.push({ from: installPath, name: depName, range: applyOverride(depName, depRange), required: false });
188
199
  }
189
200
  }
190
201
  catch (e) {
@@ -15,6 +15,18 @@ export interface InstallOptions {
15
15
  lockfile?: boolean;
16
16
  /** Use `<prefix>/gjsify-lock.json` as the source of truth — fail if missing. */
17
17
  frozen?: boolean;
18
+ /**
19
+ * Per-package version overrides — `<name> → <range>`. Applied to every
20
+ * edge during dependency resolution, irrespective of the requester.
21
+ * Mirrors npm's top-level `overrides` field and yarn's `resolutions`
22
+ * (the simple, name-only flavour; pattern keys like `typescript@*` are
23
+ * normalised to bare `typescript` by the caller before passing in).
24
+ *
25
+ * Lets a workspace root pin a transitive dep version when the
26
+ * deduplicated tree would otherwise pick a different one — e.g. for
27
+ * forcing `typescript@~5.9` across every `typescript@*` devDep.
28
+ */
29
+ overrides?: Record<string, string>;
18
30
  }
19
31
  export interface InstallResult {
20
32
  /** Top-level packages that were requested, with the version each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.9",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,18 +37,18 @@
37
37
  "cli"
38
38
  ],
39
39
  "dependencies": {
40
- "@gjsify/buffer": "^0.4.4",
41
- "@gjsify/create-app": "^0.4.4",
42
- "@gjsify/node-globals": "^0.4.4",
43
- "@gjsify/node-polyfills": "^0.4.4",
44
- "@gjsify/npm-registry": "^0.4.4",
45
- "@gjsify/resolve-npm": "^0.4.4",
46
- "@gjsify/rolldown-plugin-gjsify": "^0.4.4",
47
- "@gjsify/rolldown-plugin-pnp": "^0.4.4",
48
- "@gjsify/semver": "^0.4.4",
49
- "@gjsify/tar": "^0.4.4",
50
- "@gjsify/web-polyfills": "^0.4.4",
51
- "@gjsify/workspace": "^0.4.4",
40
+ "@gjsify/buffer": "^0.4.9",
41
+ "@gjsify/create-app": "^0.4.9",
42
+ "@gjsify/node-globals": "^0.4.9",
43
+ "@gjsify/node-polyfills": "^0.4.9",
44
+ "@gjsify/npm-registry": "^0.4.9",
45
+ "@gjsify/resolve-npm": "^0.4.9",
46
+ "@gjsify/rolldown-plugin-gjsify": "^0.4.9",
47
+ "@gjsify/rolldown-plugin-pnp": "^0.4.9",
48
+ "@gjsify/semver": "^0.4.9",
49
+ "@gjsify/tar": "^0.4.9",
50
+ "@gjsify/web-polyfills": "^0.4.9",
51
+ "@gjsify/workspace": "^0.4.9",
52
52
  "cosmiconfig": "^9.0.1",
53
53
  "get-tsconfig": "^4.14.0",
54
54
  "pkg-types": "^2.3.1",
@@ -56,12 +56,12 @@
56
56
  "yargs": "^18.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@gjsify/unit": "^0.4.4",
59
+ "@gjsify/unit": "^0.4.9",
60
60
  "@types/yargs": "^17.0.35",
61
61
  "typescript": "^6.0.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@gjsify/rolldown-native": "^0.4.4"
64
+ "@gjsify/rolldown-native": "^0.4.9"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@gjsify/rolldown-native": {