@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.
@@ -12,3 +12,5 @@ export * from './dlx.js';
12
12
  export * from './install.js';
13
13
  export * from './foreach.js';
14
14
  export * from './workspace.js';
15
+ export * from './pack.js';
16
+ export * from './publish.js';
@@ -12,3 +12,5 @@ export * from './dlx.js';
12
12
  export * from './install.js';
13
13
  export * from './foreach.js';
14
14
  export * from './workspace.js';
15
+ export * from './pack.js';
16
+ export * from './publish.js';
@@ -248,6 +248,13 @@ async function workspaceInstall(cwd, args) {
248
248
  }
249
249
  }
250
250
  console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
251
+ // Read top-level package.json's `overrides` (npm-native) or `resolutions`
252
+ // (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
253
+ // Both are flattened to a name → version map and passed to the install
254
+ // backend. Pattern keys like `typescript@*` are normalised to bare names —
255
+ // we don't yet support per-parent scoping (npm's nested overrides shape).
256
+ const rootManifest = workspaces.find((w) => w.location === cwd)?.manifest;
257
+ const overrides = extractOverrides(rootManifest);
251
258
  if (externalSpecs.size > 0) {
252
259
  await installPackages({
253
260
  prefix: cwd,
@@ -255,6 +262,7 @@ async function workspaceInstall(cwd, args) {
255
262
  verbose: args.verbose,
256
263
  lockfile: !args.immutable,
257
264
  frozen: args.immutable,
265
+ overrides,
258
266
  });
259
267
  }
260
268
  else if (args.verbose) {
@@ -386,6 +394,51 @@ async function workspaceInstall(cwd, args) {
386
394
  * different cwds that consumers (`yarn run`, `npm run`, direct PATH
387
395
  * invocation) call us from.
388
396
  */
397
+ /**
398
+ * Flatten npm `overrides` or yarn `resolutions` into a bare name → range map.
399
+ *
400
+ * Supports two input shapes:
401
+ *
402
+ * "overrides": { "typescript": "~5.9.2" } (npm)
403
+ * "resolutions": { "typescript@*": "~5.9.2" } (yarn pattern)
404
+ *
405
+ * Pattern keys with a version glob (`name@*`, `name@^x`) are normalised to the
406
+ * bare name — gjsify's resolver doesn't yet support per-incoming-range
407
+ * scoping. Object-valued nested overrides (npm's per-parent shape, e.g.
408
+ * `"foo": { ".": "1.0", "bar": "2.0" }`) are intentionally ignored; they would
409
+ * silently misbehave without per-parent support, so we surface a warning
410
+ * instead of half-applying them.
411
+ *
412
+ * Keys beginning with `_` are skipped (convention for documentation entries
413
+ * like `"_comment_typescript"` used in the wild).
414
+ */
415
+ function extractOverrides(rootManifest) {
416
+ if (!rootManifest)
417
+ return undefined;
418
+ const out = {};
419
+ const merge = (source, fieldName) => {
420
+ if (!source)
421
+ return;
422
+ for (const [key, value] of Object.entries(source)) {
423
+ if (key.startsWith('_'))
424
+ continue;
425
+ if (typeof value !== 'string') {
426
+ console.warn(`gjsify install: ${fieldName}["${key}"] is not a string — nested override shape isn't supported yet, skipping`);
427
+ continue;
428
+ }
429
+ // Normalise pattern keys (`name@*`, `name@^range`) → bare name.
430
+ // For scoped packages preserve the leading `@`.
431
+ let name = key;
432
+ const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
433
+ if (atIdx > 0)
434
+ name = key.slice(0, atIdx);
435
+ out[name] = value;
436
+ }
437
+ };
438
+ merge(rootManifest.overrides, 'overrides');
439
+ merge(rootManifest.resolutions, 'resolutions');
440
+ return Object.keys(out).length > 0 ? out : undefined;
441
+ }
389
442
  function buildBinShim(wsLocation, nodeTarget, gjsTarget) {
390
443
  const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
391
444
  const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
@@ -0,0 +1,40 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface PackOptions {
3
+ path?: string;
4
+ 'pack-destination'?: string;
5
+ json?: boolean;
6
+ 'dry-run'?: boolean;
7
+ }
8
+ interface PackResult {
9
+ filename: string;
10
+ name: string;
11
+ version: string;
12
+ size: number;
13
+ unpackedSize: number;
14
+ shasum: string;
15
+ integrity: string;
16
+ entryCount: number;
17
+ files: {
18
+ path: string;
19
+ size: number;
20
+ mode: number;
21
+ }[];
22
+ /** Absolute path of the written .tgz, or null on --dry-run. */
23
+ absolutePath: string | null;
24
+ }
25
+ export declare const packCommand: Command<any, PackOptions>;
26
+ export interface PackWorkspaceOptions {
27
+ /** Directory to write the .tgz into. Defaults to the workspace itself. */
28
+ destination?: string;
29
+ /** Skip writing the .tgz; metadata is still computed (for npm-compat callers). */
30
+ dryRun?: boolean;
31
+ /** Skip the workspace:^ rewrite step (rare — useful for testing the raw layout). */
32
+ skipWorkspaceRewrite?: boolean;
33
+ }
34
+ /**
35
+ * Programmatic equivalent of the `pack` command — used by `gjsify publish`
36
+ * to avoid spawning a subprocess. Caller is responsible for resolving
37
+ * `wsDir` to an absolute path.
38
+ */
39
+ export declare function packWorkspace(wsDir: string, opts?: PackWorkspaceOptions): Promise<PackResult>;
40
+ export {};
@@ -0,0 +1,335 @@
1
+ // `gjsify pack [path] [--pack-destination <dir>] [--json]` — npm-compatible
2
+ // tarball creation for the workspace at `path` (default: cwd).
3
+ //
4
+ // Output shape matches `npm pack --json`:
5
+ // [
6
+ // {
7
+ // "filename": "scope-name-1.2.3.tgz",
8
+ // "name": "@scope/name",
9
+ // "version": "1.2.3",
10
+ // "size": <unpacked bytes>,
11
+ // "unpackedSize": <unpacked bytes>,
12
+ // "shasum": "<sha1 hex>",
13
+ // "integrity": "sha512-<base64>",
14
+ // "files": [ { path, size, mode }, ... ],
15
+ // "entryCount": <int>
16
+ // }
17
+ // ]
18
+ //
19
+ // File selection mirrors npm pack: explicit `files` field if present, else
20
+ // the default allowlist (README/LICENSE/package.json + main entry + bin). The
21
+ // implementation here is conservative — when no `files` field is set we walk
22
+ // the workspace recursively and apply the implicit-exclusion set
23
+ // (.git, node_modules, …) plus .npmignore / .gitignore. workspace:^ deps in
24
+ // package.json are rewritten to resolved npm version ranges based on the
25
+ // sibling workspaces' own versions, so the published tarball is consumable.
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
27
+ import { createHash } from 'node:crypto';
28
+ import { join, resolve } from 'node:path';
29
+ import { createTarball, gzip } from '@gjsify/tar';
30
+ import { discoverWorkspaces } from '@gjsify/workspace';
31
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
32
+ export const packCommand = {
33
+ command: 'pack [path]',
34
+ description: 'Produce an npm-compatible .tgz tarball for the workspace at <path> (default: cwd). Rewrites workspace:^/~/* deps to resolved versions.',
35
+ builder: (yargs) => yargs
36
+ .positional('path', {
37
+ description: 'Workspace path (default: cwd).',
38
+ type: 'string',
39
+ })
40
+ .option('pack-destination', {
41
+ description: 'Directory to write the tarball into. Default: workspace cwd.',
42
+ type: 'string',
43
+ })
44
+ .option('json', {
45
+ description: 'Emit pack metadata as JSON on stdout (mirrors `npm pack --json`).',
46
+ type: 'boolean',
47
+ default: false,
48
+ })
49
+ .option('dry-run', {
50
+ description: 'Compute everything but do not write the .tgz.',
51
+ type: 'boolean',
52
+ default: false,
53
+ }),
54
+ handler: async (args) => {
55
+ const wsDir = resolve(args.path ?? process.cwd());
56
+ const result = await packWorkspace(wsDir, {
57
+ destination: args['pack-destination'],
58
+ dryRun: args['dry-run'] === true,
59
+ });
60
+ if (args.json) {
61
+ process.stdout.write(`${JSON.stringify([result], null, 2)}\n`);
62
+ }
63
+ else {
64
+ process.stdout.write(`${result.filename}\n`);
65
+ }
66
+ },
67
+ };
68
+ /**
69
+ * Programmatic equivalent of the `pack` command — used by `gjsify publish`
70
+ * to avoid spawning a subprocess. Caller is responsible for resolving
71
+ * `wsDir` to an absolute path.
72
+ */
73
+ export async function packWorkspace(wsDir, opts = {}) {
74
+ const pkgPath = join(wsDir, 'package.json');
75
+ if (!existsSync(pkgPath)) {
76
+ throw new Error(`gjsify pack: no package.json at ${wsDir}`);
77
+ }
78
+ const originalSource = readFileSync(pkgPath, 'utf-8');
79
+ const pkg = JSON.parse(originalSource);
80
+ const name = typeof pkg.name === 'string' ? pkg.name : '';
81
+ const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
82
+ if (!name) {
83
+ throw new Error(`gjsify pack: package.json at ${wsDir} has no "name"`);
84
+ }
85
+ // Rewrite workspace:^/~/* deps to resolved npm version ranges, mirroring
86
+ // yarn's auto-rewrite at publish time. Done in-memory only — the source
87
+ // package.json on disk is never mutated by `gjsify pack`.
88
+ const rewrittenPkg = opts.skipWorkspaceRewrite
89
+ ? pkg
90
+ : rewriteWorkspaceDeps(pkg, wsDir);
91
+ const rewrittenSource = JSON.stringify(rewrittenPkg, null, indentOf(originalSource)) + '\n';
92
+ // Collect files according to the package.json `files` field (or npm's
93
+ // default set). The package.json itself is always included with the
94
+ // rewritten contents.
95
+ const filesToPack = collectFiles(wsDir, pkg);
96
+ const entries = [{ name: 'package/', directory: true, mode: 0o755 }];
97
+ const fileMetas = [];
98
+ let unpackedSize = 0;
99
+ for (const rel of filesToPack) {
100
+ let body;
101
+ if (rel === 'package.json') {
102
+ body = new TextEncoder().encode(rewrittenSource);
103
+ }
104
+ else {
105
+ body = new Uint8Array(readFileSync(join(wsDir, rel)));
106
+ }
107
+ const st = statSync(join(wsDir, rel));
108
+ const mode = st.mode & 0o777;
109
+ entries.push({ name: `package/${rel}`, body, mode, mtime: 0 });
110
+ fileMetas.push({ path: rel, size: body.byteLength, mode });
111
+ unpackedSize += body.byteLength;
112
+ }
113
+ const tarBytes = createTarball(entries);
114
+ const gzipBytes = await gzip(tarBytes);
115
+ // npm filename: scope replaced with leading dash. "@gjsify/foo" → "gjsify-foo".
116
+ const filenameBase = name.startsWith('@')
117
+ ? name.slice(1).replace('/', '-')
118
+ : name;
119
+ const filename = `${filenameBase}-${version}.tgz`;
120
+ const sha1 = createHash('sha1').update(gzipBytes).digest('hex');
121
+ const sha512 = createHash('sha512').update(gzipBytes).digest('base64');
122
+ const integrity = `sha512-${sha512}`;
123
+ const destDir = opts.destination ? resolve(opts.destination) : wsDir;
124
+ const tarPath = join(destDir, filename);
125
+ if (!opts.dryRun) {
126
+ mkdirSync(destDir, { recursive: true });
127
+ writeFileSync(tarPath, gzipBytes);
128
+ }
129
+ return {
130
+ filename,
131
+ name,
132
+ version,
133
+ size: gzipBytes.byteLength,
134
+ unpackedSize,
135
+ shasum: sha1,
136
+ integrity,
137
+ entryCount: fileMetas.length,
138
+ files: fileMetas,
139
+ absolutePath: opts.dryRun ? null : tarPath,
140
+ };
141
+ }
142
+ /**
143
+ * Walk the workspace and return the list of files to include in the tarball,
144
+ * relative to `wsDir`. Mirrors npm pack's selection rules:
145
+ *
146
+ * - If pkg.files exists: use it as an allowlist (with .npmignore as a
147
+ * blacklist within those globs)
148
+ * - Otherwise: walk everything, apply .npmignore + .gitignore, drop the
149
+ * implicit-exclusion set (node_modules, .git, …)
150
+ *
151
+ * package.json, README*, LICENSE*, NOTICE* and the `bin`/`main` files are
152
+ * always force-included regardless of the rules above.
153
+ */
154
+ function collectFiles(wsDir, pkg) {
155
+ const always = forceIncluded(pkg);
156
+ const filesField = Array.isArray(pkg.files) ? pkg.files.filter((f) => typeof f === 'string') : null;
157
+ let candidates;
158
+ if (filesField) {
159
+ candidates = expandFilesPatterns(wsDir, filesField);
160
+ }
161
+ else {
162
+ candidates = walkAll(wsDir);
163
+ }
164
+ const ignore = loadIgnore(wsDir);
165
+ const out = new Set();
166
+ for (const f of candidates) {
167
+ if (!ignore(f))
168
+ out.add(f);
169
+ }
170
+ for (const f of always) {
171
+ if (existsSync(join(wsDir, f)))
172
+ out.add(f);
173
+ }
174
+ return [...out].sort();
175
+ }
176
+ const ALWAYS_INCLUDED_BASENAMES = new Set(['package.json', 'README', 'README.md', 'LICENSE', 'LICENSE.md', 'NOTICE', 'NOTICE.md']);
177
+ const NEVER_INCLUDED_BASENAMES = new Set([
178
+ '.git', '.svn', '.hg', '.gitignore', '.gitattributes', '.npmrc',
179
+ 'CVS', '.DS_Store', 'node_modules', '.npmignore', 'package-lock.json',
180
+ 'gjsify-lock.json', 'yarn.lock', 'yarn-error.log', '.yarn',
181
+ '.pnp.cjs', '.pnp.loader.mjs', 'tsconfig.tsbuildinfo',
182
+ ]);
183
+ function forceIncluded(pkg) {
184
+ const out = new Set();
185
+ out.add('package.json');
186
+ for (const name of ['README', 'README.md', 'LICENSE', 'LICENSE.md', 'NOTICE', 'NOTICE.md']) {
187
+ out.add(name);
188
+ }
189
+ const main = typeof pkg.main === 'string' ? pkg.main : null;
190
+ if (main)
191
+ out.add(main.replace(/^\.\//, ''));
192
+ const bin = pkg.bin;
193
+ if (typeof bin === 'string') {
194
+ out.add(bin.replace(/^\.\//, ''));
195
+ }
196
+ else if (bin && typeof bin === 'object') {
197
+ for (const v of Object.values(bin)) {
198
+ if (typeof v === 'string')
199
+ out.add(v.replace(/^\.\//, ''));
200
+ }
201
+ }
202
+ return [...out];
203
+ }
204
+ function walkAll(root, sub = '') {
205
+ const out = [];
206
+ const here = sub ? join(root, sub) : root;
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(here, { withFileTypes: true });
210
+ }
211
+ catch {
212
+ return out;
213
+ }
214
+ for (const entry of entries) {
215
+ if (NEVER_INCLUDED_BASENAMES.has(entry.name))
216
+ continue;
217
+ if (entry.name.startsWith('.tsbuildinfo'))
218
+ continue;
219
+ const rel = sub ? `${sub}/${entry.name}` : entry.name;
220
+ if (entry.isDirectory()) {
221
+ out.push(...walkAll(root, rel));
222
+ }
223
+ else if (entry.isFile()) {
224
+ out.push(rel);
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+ function expandFilesPatterns(wsDir, patterns) {
230
+ const out = new Set();
231
+ for (const pattern of patterns) {
232
+ // Drop leading ./
233
+ const normalized = pattern.replace(/^\.\//, '').replace(/\/$/, '');
234
+ const full = join(wsDir, normalized);
235
+ if (!existsSync(full))
236
+ continue;
237
+ const st = statSync(full);
238
+ if (st.isDirectory()) {
239
+ for (const f of walkAll(wsDir, normalized))
240
+ out.add(f);
241
+ }
242
+ else if (st.isFile()) {
243
+ out.add(normalized);
244
+ }
245
+ // TODO: glob patterns (foo/*.js). Currently we treat the entry as a
246
+ // literal file or directory. Most monorepos use file/dir entries only
247
+ // (lib, dist, prebuilds) — globs are rare. Surface a warning if the
248
+ // pattern contains glob chars and didn't resolve.
249
+ if (!existsSync(full) && /[*?[]/.test(pattern)) {
250
+ console.warn(`gjsify pack: files entry "${pattern}" looks like a glob but glob expansion isn't implemented — pass literal files/dirs`);
251
+ }
252
+ }
253
+ return [...out];
254
+ }
255
+ function loadIgnore(wsDir) {
256
+ // .npmignore takes precedence over .gitignore (npm semantics).
257
+ const npmIgnorePath = join(wsDir, '.npmignore');
258
+ const gitIgnorePath = join(wsDir, '.gitignore');
259
+ const patterns = [];
260
+ const sourcePath = existsSync(npmIgnorePath) ? npmIgnorePath : (existsSync(gitIgnorePath) ? gitIgnorePath : null);
261
+ if (sourcePath) {
262
+ const lines = readFileSync(sourcePath, 'utf-8').split('\n');
263
+ for (const raw of lines) {
264
+ const line = raw.trim();
265
+ if (!line || line.startsWith('#'))
266
+ continue;
267
+ // Simple translation: pattern → regex anchored to start, with `*` → `[^/]*`
268
+ // and `**` → `.*`. Negations (`!`) are skipped.
269
+ if (line.startsWith('!'))
270
+ continue;
271
+ patterns.push(globToRegex(line));
272
+ }
273
+ }
274
+ return (rel) => {
275
+ for (const p of patterns)
276
+ if (p.test(rel))
277
+ return true;
278
+ return false;
279
+ };
280
+ }
281
+ function globToRegex(glob) {
282
+ // Strip leading / (anchors to root)
283
+ let pat = glob.replace(/^\//, '');
284
+ // Escape regex metachars except *,?,/
285
+ pat = pat.replace(/[.+^${}()|[\]\\]/g, '\\$&');
286
+ // ** → .* * → [^/]* ? → [^/]
287
+ pat = pat.replace(/\*\*/g, '__DOUBLESTAR__').replace(/\*/g, '[^/]*').replace(/__DOUBLESTAR__/g, '.*');
288
+ pat = pat.replace(/\?/g, '[^/]');
289
+ return new RegExp(`^${pat}($|/)`);
290
+ }
291
+ /**
292
+ * Walk pkg.dependencies / devDependencies / peerDependencies /
293
+ * optionalDependencies and replace `workspace:^` / `workspace:~` /
294
+ * `workspace:*` ranges with the resolved npm range based on each sibling
295
+ * workspace's version field.
296
+ */
297
+ function rewriteWorkspaceDeps(pkg, wsDir) {
298
+ const root = findWorkspaceRoot(wsDir);
299
+ if (!root)
300
+ return pkg;
301
+ const siblings = new Map();
302
+ for (const ws of discoverWorkspaces(root)) {
303
+ if (ws.name && ws.version)
304
+ siblings.set(ws.name, ws.version);
305
+ }
306
+ const cloned = JSON.parse(JSON.stringify(pkg));
307
+ for (const block of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
308
+ const deps = cloned[block];
309
+ if (!deps)
310
+ continue;
311
+ for (const [name, range] of Object.entries(deps)) {
312
+ if (typeof range !== 'string' || !range.startsWith('workspace:'))
313
+ continue;
314
+ const wsVer = siblings.get(name);
315
+ if (!wsVer) {
316
+ throw new Error(`gjsify pack: ${cloned.name} declares workspace:^ on ${name} but no sibling workspace with that name exists in the monorepo at ${root}`);
317
+ }
318
+ const operator = range.slice('workspace:'.length);
319
+ if (operator === '*' || operator === '') {
320
+ deps[name] = wsVer;
321
+ }
322
+ else if (operator === '^' || operator === '~') {
323
+ deps[name] = `${operator}${wsVer}`;
324
+ }
325
+ else {
326
+ deps[name] = operator;
327
+ }
328
+ }
329
+ }
330
+ return cloned;
331
+ }
332
+ function indentOf(source) {
333
+ const m = source.match(/\n([ \t]+)"/);
334
+ return m ? m[1] : ' ';
335
+ }
@@ -0,0 +1,12 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface PublishOptions {
3
+ path?: string;
4
+ tag?: string;
5
+ access?: string;
6
+ 'tolerate-republish'?: boolean;
7
+ provenance?: boolean;
8
+ 'dry-run'?: boolean;
9
+ json?: boolean;
10
+ }
11
+ export declare const publishCommand: Command<any, PublishOptions>;
12
+ export {};