@gjsify/cli 0.4.36 → 0.4.37

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.
@@ -30,32 +30,28 @@
30
30
  // same hex bytes share the same cache entry; that is the invariant pnpm
31
31
  // relies on too. Tarballs without an integrity hash (older registries)
32
32
  // fall through to a no-op cache and download every time.
33
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
33
+ import { existsSync, statSync } from 'node:fs';
34
34
  import { homedir } from 'node:os';
35
35
  import { join } from 'node:path';
36
+ import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
36
37
  const CACHE_LAYOUT_VERSION = 'v1';
37
- /**
38
- * Resolve the root of the tarball cache. Mirrors the dlx cache's
39
- * XDG-honouring lookup so users with a custom `XDG_CACHE_HOME` get a
40
- * single coherent cache root.
41
- */
38
+ /** Root of the tarball cache: `$XDG_CACHE_HOME/gjsify/tarballs/v1`. */
42
39
  function cacheRoot() {
43
- const xdg = process.env.XDG_CACHE_HOME;
44
- const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
45
- return join(base, 'gjsify', 'tarballs', CACHE_LAYOUT_VERSION);
40
+ return gjsifyCacheRoot('tarballs', CACHE_LAYOUT_VERSION);
46
41
  }
47
42
  /**
48
- * Convert an SRI integrity string (`sha512-AbCd…=`) into a cache file path.
49
- * Returns `null` for unsupported / malformed integrity values so the caller
50
- * can fall back to a fresh download.
43
+ * Parse an SRI integrity string (`sha512-AbCd…=`) into its algorithm + hex
44
+ * digest, or `null` for a missing / malformed value (caller falls back to a
45
+ * fresh download). Shared by both the gjsify-store and npm-cacache path
46
+ * derivations below — the base64→hex decode used to be inlined in each.
51
47
  */
52
- function pathFor(integrity) {
48
+ function parseSri(integrity) {
53
49
  if (!integrity)
54
50
  return null;
55
51
  const dashIdx = integrity.indexOf('-');
56
52
  if (dashIdx <= 0 || dashIdx === integrity.length - 1)
57
53
  return null;
58
- const algo = integrity.slice(0, dashIdx);
54
+ const algorithm = integrity.slice(0, dashIdx);
59
55
  const b64 = integrity.slice(dashIdx + 1).replace(/=+$/, '');
60
56
  // Decode base64 → hex; throws on malformed input which we swallow.
61
57
  let hex;
@@ -67,8 +63,19 @@ function pathFor(integrity) {
67
63
  }
68
64
  if (hex.length < 4)
69
65
  return null;
70
- const shard = hex.slice(0, 2);
71
- return join(cacheRoot(), algo, shard, `${hex}.tgz`);
66
+ return { algorithm, hex };
67
+ }
68
+ /**
69
+ * Convert an SRI integrity string into a cache file path. Returns `null` for
70
+ * unsupported / malformed integrity values so the caller can fall back to a
71
+ * fresh download.
72
+ */
73
+ function pathFor(integrity) {
74
+ const sri = parseSri(integrity);
75
+ if (!sri)
76
+ return null;
77
+ const shard = sri.hex.slice(0, 2);
78
+ return join(cacheRoot(), sri.algorithm, shard, `${sri.hex}.tgz`);
72
79
  }
73
80
  /**
74
81
  * Read a cached tarball by SRI integrity. Returns the raw tarball bytes if
@@ -80,18 +87,7 @@ export function getCachedTarball(integrity) {
80
87
  const path = pathFor(integrity);
81
88
  if (!path)
82
89
  return null;
83
- if (!existsSync(path))
84
- return null;
85
- try {
86
- const buf = readFileSync(path);
87
- // Sanity: a zero-byte file is a previous-write failure; treat as MISS.
88
- if (buf.length === 0)
89
- return null;
90
- return buf;
91
- }
92
- catch {
93
- return null;
94
- }
90
+ return readCacheFile(path);
95
91
  }
96
92
  /**
97
93
  * Persist a tarball to the cache. Writes to a `<path>.tmp.<pid>` sibling
@@ -106,18 +102,10 @@ export function putCachedTarball(integrity, bytes) {
106
102
  const path = pathFor(integrity);
107
103
  if (!path)
108
104
  return;
105
+ // Idempotent: content-addressed entries are immutable, never rewritten.
109
106
  if (existsSync(path))
110
107
  return;
111
- try {
112
- mkdirSync(join(path, '..'), { recursive: true });
113
- const tmp = `${path}.tmp.${process.pid}`;
114
- writeFileSync(tmp, bytes);
115
- renameSync(tmp, path);
116
- }
117
- catch {
118
- // Cache write failure is non-fatal — the install proceeds with the
119
- // in-memory bytes; we just won't get a hit on the next run.
120
- }
108
+ atomicWrite(path, bytes);
121
109
  }
122
110
  /**
123
111
  * Best-effort cache stats for diagnostics. Returns `null` when the cache
@@ -138,3 +126,67 @@ export function isCacheHit(integrity) {
138
126
  return false;
139
127
  }
140
128
  }
129
+ // ---------------------------------------------------------------------------
130
+ // Foreign cache interop — read npm's cacache content store.
131
+ //
132
+ // npm stores downloaded tarballs in a content-addressable store
133
+ // (`cacache`) keyed by the SAME SRI integrity we key our own store on, and
134
+ // holds the raw `.tgz` bytes verbatim. Layout (cacache `content-v2`):
135
+ //
136
+ // <npm-cache>/_cacache/content-v2/<algo>/<hex[0:2]>/<hex[2:4]>/<hex[4:]>
137
+ //
138
+ // `<algo>` is the SRI algorithm (`sha512`), `<hex>` the hex-encoded digest —
139
+ // identical derivation to our own `pathFor`, just a different root and no
140
+ // `.tgz` extension. So anyone who has run `npm install` before already has
141
+ // these tarballs on disk; reading them turns a cold `gjsify install` into a
142
+ // near-warm one without a single network round-trip.
143
+ //
144
+ // pnpm/yarn/bun stores are deliberately NOT read here: pnpm/bun store
145
+ // *unpacked* per-file content (no tarball to hand to the extractor) and yarn
146
+ // berry stores zip archives under its own (non-SRI) cache key — none map to a
147
+ // tarball-by-integrity lookup the way npm's cacache does.
148
+ // ---------------------------------------------------------------------------
149
+ /**
150
+ * Resolve npm's `content-v2` directory, honouring `GJSIFY_NPM_CACHE`
151
+ * (full path to a `_cacache` dir; `0`/`false`/empty disables the interop),
152
+ * then `npm_config_cache`, then the platform default `~/.npm`. Returns `null`
153
+ * when the interop is disabled or no plausible cache root exists.
154
+ */
155
+ function npmCacacheContentDir() {
156
+ const override = process.env.GJSIFY_NPM_CACHE;
157
+ if (override !== undefined) {
158
+ const trimmed = override.trim();
159
+ if (trimmed === '' || trimmed === '0' || trimmed === 'false')
160
+ return null;
161
+ // Accept either the `_cacache` dir itself or its parent npm cache dir.
162
+ const base = trimmed.endsWith('_cacache') ? trimmed : join(trimmed, '_cacache');
163
+ return join(base, 'content-v2');
164
+ }
165
+ const npmConfigCache = process.env.npm_config_cache;
166
+ const cacheBase = npmConfigCache && npmConfigCache.length > 0 ? npmConfigCache : join(homedir(), '.npm');
167
+ return join(cacheBase, '_cacache', 'content-v2');
168
+ }
169
+ /** Map an SRI integrity to its npm cacache content-store path, or `null`. */
170
+ function npmCachePathFor(integrity) {
171
+ const contentDir = npmCacacheContentDir();
172
+ if (!contentDir)
173
+ return null;
174
+ const sri = parseSri(integrity);
175
+ if (!sri)
176
+ return null;
177
+ // cacache shards the hex digest into [0:2]/[2:4]/[4:] with NO extension.
178
+ return join(contentDir, sri.algorithm, sri.hex.slice(0, 2), sri.hex.slice(2, 4), sri.hex.slice(4));
179
+ }
180
+ /**
181
+ * Read a tarball from npm's cacache content store by SRI integrity. Returns
182
+ * the raw `.tgz` bytes on a HIT, `null` on a MISS / disabled interop / read
183
+ * failure. Like {@link getCachedTarball}, this trusts the content-addressed
184
+ * path rather than re-hashing — the extractor surfaces any genuinely corrupt
185
+ * tarball loudly, and cacache verified the bytes on write.
186
+ */
187
+ export function getForeignCachedTarball(integrity) {
188
+ const path = npmCachePathFor(integrity);
189
+ if (!path)
190
+ return null;
191
+ return readCacheFile(path);
192
+ }
@@ -0,0 +1,8 @@
1
+ /** Print a question and read one line from stdin (visible). */
2
+ export declare function promptLine(question: string): Promise<string>;
3
+ /**
4
+ * Print a question and read one line WITHOUT echoing it (passwords). Uses raw
5
+ * mode + manual key handling on a TTY; falls back to a plain line read when
6
+ * stdin is not a TTY (piped). Ctrl-C aborts the process.
7
+ */
8
+ export declare function promptHidden(question: string): Promise<string>;
@@ -0,0 +1,92 @@
1
+ // Minimal interactive prompts for `gjsify login` — a visible line prompt and a
2
+ // hidden (no-echo) password prompt. Cross-runtime: `process.stdin.setRawMode`
3
+ // is provided by Node and by `@gjsify/process` (terminal-native) under GJS.
4
+ //
5
+ // Non-TTY stdin (piped input, CI) is supported: both helpers read a single line
6
+ // without raw-mode masking, so `printf 'user\npass\n' | gjsify login` works for
7
+ // automation.
8
+ const CTRL_C = String.fromCharCode(3); // ETX (Ctrl-C)
9
+ const DEL = String.fromCharCode(127); // DEL (Backspace on most terminals)
10
+ const BACKSPACE = String.fromCharCode(8); // BS
11
+ /** Print a question and read one line from stdin (visible). */
12
+ export async function promptLine(question) {
13
+ process.stdout.write(question);
14
+ return readLine();
15
+ }
16
+ /**
17
+ * Print a question and read one line WITHOUT echoing it (passwords). Uses raw
18
+ * mode + manual key handling on a TTY; falls back to a plain line read when
19
+ * stdin is not a TTY (piped). Ctrl-C aborts the process.
20
+ */
21
+ export async function promptHidden(question) {
22
+ process.stdout.write(question);
23
+ const stdin = process.stdin;
24
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
25
+ // Non-interactive: read a line as-is (no masking possible/needed).
26
+ return readLine();
27
+ }
28
+ return new Promise((resolve) => {
29
+ let buf = '';
30
+ stdin.setRawMode(true);
31
+ stdin.resume();
32
+ stdin.setEncoding('utf-8');
33
+ const onData = (chunk) => {
34
+ for (const ch of chunk) {
35
+ if (ch === '\r' || ch === '\n') {
36
+ cleanup();
37
+ process.stdout.write('\n');
38
+ resolve(buf);
39
+ return;
40
+ }
41
+ else if (ch === CTRL_C) {
42
+ cleanup();
43
+ process.stdout.write('\n');
44
+ process.exit(130);
45
+ }
46
+ else if (ch === DEL || ch === BACKSPACE) {
47
+ if (buf.length > 0) {
48
+ buf = buf.slice(0, -1);
49
+ process.stdout.write('\b \b');
50
+ }
51
+ }
52
+ else if (ch >= ' ') {
53
+ buf += ch;
54
+ process.stdout.write('*');
55
+ }
56
+ }
57
+ };
58
+ const cleanup = () => {
59
+ stdin.setRawMode(false);
60
+ stdin.removeListener('data', onData);
61
+ stdin.pause();
62
+ };
63
+ stdin.on('data', onData);
64
+ });
65
+ }
66
+ /** Read a single line from stdin (shared by both prompts on non-TTY). */
67
+ function readLine() {
68
+ return new Promise((resolve) => {
69
+ let buf = '';
70
+ const onData = (chunk) => {
71
+ buf += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
72
+ const nl = buf.indexOf('\n');
73
+ if (nl >= 0) {
74
+ cleanup();
75
+ resolve(buf.slice(0, nl).replace(/\r$/, ''));
76
+ }
77
+ };
78
+ const onEnd = () => {
79
+ cleanup();
80
+ resolve(buf.replace(/\r$/, '').trim());
81
+ };
82
+ const cleanup = () => {
83
+ process.stdin.removeListener('data', onData);
84
+ process.stdin.removeListener('end', onEnd);
85
+ };
86
+ process.stdin.setEncoding('utf-8');
87
+ if (typeof process.stdin.isPaused === 'function' && process.stdin.isPaused())
88
+ process.stdin.resume();
89
+ process.stdin.on('data', onData);
90
+ process.stdin.once('end', onEnd);
91
+ });
92
+ }
@@ -70,11 +70,15 @@ function formatLiveToken404(name, version, username) {
70
70
  '',
71
71
  `Authenticated as: ${username}`,
72
72
  '',
73
- `The package ${name} does not yet exist on npmjs.com. For a brand-new`,
74
- 'scoped package, this usually means the first-publish bootstrap step is needed',
75
- '(see AGENTS.md > "New @gjsify/* package: first-publish + Trusted Publisher',
76
- 'bootstrap"). If you\'re sure you have write access to the scope, verify with:',
77
- ' npm access ls-packages',
73
+ `Your token authenticates, so this is NOT a dead-token problem. The package`,
74
+ `${name} is not (yet) on npmjs.com. Two cases:`,
75
+ '',
76
+ ' 1. First publish of a brand-new scoped package do the one-time bootstrap',
77
+ ' (see AGENTS.md > "New @gjsify/* package: first-publish + Trusted',
78
+ ' Publisher bootstrap"). The npm registry can also 404 *transiently*',
79
+ ' while provisioning a brand-new package: simply re-run, or do the very',
80
+ ' first publish with `npm publish` (then configure the Trusted Publisher).',
81
+ ' 2. You lack publish access to the scope — verify with `npm access ls-packages`.',
78
82
  ].join('\n');
79
83
  }
80
84
  function formatUnknown(name, version) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.4.36",
3
+ "version": "0.4.37",
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.4.36",
124
- "@gjsify/create-app": "^0.4.36",
125
- "@gjsify/node-globals": "^0.4.36",
126
- "@gjsify/node-polyfills": "^0.4.36",
127
- "@gjsify/npm-registry": "^0.4.36",
128
- "@gjsify/resolve-npm": "^0.4.36",
129
- "@gjsify/rolldown-plugin-gjsify": "^0.4.36",
130
- "@gjsify/rolldown-plugin-pnp": "^0.4.36",
131
- "@gjsify/semver": "^0.4.36",
132
- "@gjsify/tar": "^0.4.36",
133
- "@gjsify/tsc": "^0.4.36",
134
- "@gjsify/web-polyfills": "^0.4.36",
135
- "@gjsify/workspace": "^0.4.36",
123
+ "@gjsify/buffer": "^0.4.37",
124
+ "@gjsify/create-app": "^0.4.37",
125
+ "@gjsify/node-globals": "^0.4.37",
126
+ "@gjsify/node-polyfills": "^0.4.37",
127
+ "@gjsify/npm-registry": "^0.4.37",
128
+ "@gjsify/resolve-npm": "^0.4.37",
129
+ "@gjsify/rolldown-plugin-gjsify": "^0.4.37",
130
+ "@gjsify/rolldown-plugin-pnp": "^0.4.37",
131
+ "@gjsify/semver": "^0.4.37",
132
+ "@gjsify/tar": "^0.4.37",
133
+ "@gjsify/tsc": "^0.4.37",
134
+ "@gjsify/web-polyfills": "^0.4.37",
135
+ "@gjsify/workspace": "^0.4.37",
136
136
  "cosmiconfig": "^9.0.1",
137
137
  "get-tsconfig": "^4.14.0",
138
138
  "pkg-types": "^2.3.1",
@@ -140,12 +140,12 @@
140
140
  "yargs": "^18.0.0"
141
141
  },
142
142
  "devDependencies": {
143
- "@gjsify/unit": "^0.4.36",
143
+ "@gjsify/unit": "^0.4.37",
144
144
  "@types/yargs": "^17.0.35",
145
145
  "typescript": "^5.9.3"
146
146
  },
147
147
  "peerDependencies": {
148
- "@gjsify/rolldown-native": "^0.4.36"
148
+ "@gjsify/rolldown-native": "^0.4.37"
149
149
  },
150
150
  "peerDependenciesMeta": {
151
151
  "@gjsify/rolldown-native": {