@wenrwa/marketplace-cli 0.8.11 → 0.8.13

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.
Files changed (29) hide show
  1. package/dist/__tests__/bridge-cleanup-frame.test.d.ts +1 -0
  2. package/dist/__tests__/bridge-cleanup-frame.test.js +217 -0
  3. package/dist/__tests__/bridge-clone-grow-quote.test.d.ts +1 -0
  4. package/dist/__tests__/bridge-clone-grow-quote.test.js +145 -0
  5. package/dist/__tests__/bridge-disk-pressure-frame.test.d.ts +1 -0
  6. package/dist/__tests__/bridge-disk-pressure-frame.test.js +179 -0
  7. package/dist/__tests__/bridge-preview-checkout.test.js +9 -0
  8. package/dist/__tests__/bridge-storage-cleanup-real-fs.test.d.ts +1 -0
  9. package/dist/__tests__/bridge-storage-cleanup-real-fs.test.js +372 -0
  10. package/dist/__tests__/git-token-refresh.test.d.ts +1 -0
  11. package/dist/__tests__/git-token-refresh.test.js +231 -0
  12. package/dist/bridge/diskPreflight.d.ts +4 -1
  13. package/dist/bridge/diskPreflight.js +5 -3
  14. package/dist/bridge/gitTokenBroker.d.ts +28 -0
  15. package/dist/bridge/gitTokenBroker.js +124 -0
  16. package/dist/bridge/previewCheckout.d.ts +11 -0
  17. package/dist/bridge/previewCheckout.js +18 -1
  18. package/dist/bridge/relayClient.d.ts +47 -0
  19. package/dist/bridge/relayClient.js +150 -2
  20. package/dist/bridge/repoCheckout.d.ts +3 -1
  21. package/dist/bridge/repoCheckout.js +6 -1
  22. package/dist/bridge/storageCleanup.d.ts +66 -0
  23. package/dist/bridge/storageCleanup.js +337 -0
  24. package/dist/bridge/storageQuotaFetch.js +12 -0
  25. package/dist/bridge/storageQuotaPreflight.d.ts +12 -0
  26. package/dist/commands/bridge.d.ts +10 -0
  27. package/dist/commands/bridge.js +59 -3
  28. package/dist/index.js +0 -0
  29. package/package.json +1 -1
@@ -0,0 +1,372 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ /**
37
+ * stc-05 (docs/prd/storage-billed-in-credits.md, ADR-0018 + ADR-0008's floor)
38
+ * — the bridge-side cleaner against a REAL temp filesystem and REAL git
39
+ * repos. It may delete only regenerable data: package caches, preview slots
40
+ * not in use, checkouts the user already removed, and gitignored build output
41
+ * inside clean repos. Never source, `.env`, uncommitted / unpushed / stashed
42
+ * work, or anything with an attached session — and attachment/dirtiness are
43
+ * re-checked immediately before each delete.
44
+ */
45
+ const child_process_1 = require("child_process");
46
+ const fs = __importStar(require("fs"));
47
+ const os = __importStar(require("os"));
48
+ const path = __importStar(require("path"));
49
+ const storageCleanup_1 = require("../bridge/storageCleanup");
50
+ /** commands/bridge.ts execRunner, verbatim in behaviour (rejects on failure with no stdout). */
51
+ const execRunner = (cmd, args) => new Promise((resolve, reject) => {
52
+ (0, child_process_1.execFile)(cmd, args, (err, stdout) => {
53
+ if (err && !stdout)
54
+ reject(err);
55
+ else
56
+ resolve({ stdout: stdout ?? '', code: 0 });
57
+ });
58
+ });
59
+ const git = (cwd, ...args) => (0, child_process_1.execFileSync)('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8' });
60
+ const KB = 1024;
61
+ function writeBlob(p, bytes) {
62
+ fs.mkdirSync(path.dirname(p), { recursive: true });
63
+ fs.writeFileSync(p, Buffer.alloc(bytes, 7));
64
+ }
65
+ /** Total on-disk bytes under `p` (same block-based figure du reports). */
66
+ function diskBytes(p) {
67
+ let st;
68
+ try {
69
+ st = fs.lstatSync(p);
70
+ }
71
+ catch {
72
+ return 0;
73
+ }
74
+ if (!st.isDirectory())
75
+ return st.blocks * 512;
76
+ let total = st.blocks * 512;
77
+ for (const e of fs.readdirSync(p))
78
+ total += diskBytes(path.join(p, e));
79
+ return total;
80
+ }
81
+ let root;
82
+ let repos;
83
+ let previews;
84
+ let home;
85
+ let remote;
86
+ let attached;
87
+ let previewActivity;
88
+ function seedRemote() {
89
+ remote = path.join(root, 'remote.git');
90
+ (0, child_process_1.execFileSync)('git', ['init', '--quiet', '--bare', '-b', 'main', remote]);
91
+ const seed = path.join(root, 'seed');
92
+ (0, child_process_1.execFileSync)('git', ['init', '--quiet', '-b', 'main', seed]);
93
+ fs.writeFileSync(path.join(seed, 'index.ts'), 'export {};\n');
94
+ fs.writeFileSync(path.join(seed, '.gitignore'), ['node_modules/', '.next/', 'dist/', '.venv/', '.env', 'secrets/'].join('\n') + '\n');
95
+ git(seed, 'add', '-A');
96
+ git(seed, 'commit', '--quiet', '-m', 'seed');
97
+ git(seed, 'push', '--quiet', remote, 'main');
98
+ }
99
+ /** A `--single-branch` clone (how repoCheckout.ts clones) with build output + a .env. */
100
+ function checkout(ws) {
101
+ const dir = path.join(repos, ws);
102
+ (0, child_process_1.execFileSync)('git', ['clone', '--quiet', '--single-branch', remote, dir]);
103
+ writeBlob(path.join(dir, 'node_modules', 'left-pad', 'index.js'), 40 * KB);
104
+ writeBlob(path.join(dir, '.next', 'cache', 'webpack.pack'), 30 * KB);
105
+ writeBlob(path.join(dir, 'dist', 'bundle.js'), 20 * KB);
106
+ writeBlob(path.join(dir, 'packages', 'ui', 'node_modules', 'x', 'i.js'), 10 * KB);
107
+ fs.writeFileSync(path.join(dir, '.env'), 'API_KEY=only-copy\n');
108
+ writeBlob(path.join(dir, 'secrets', 'key.pem'), 2 * KB); // gitignored, but NOT build output
109
+ return dir;
110
+ }
111
+ function deps(over = {}) {
112
+ return {
113
+ reposBaseDir: repos,
114
+ previewsBaseDir: previews,
115
+ cacheDirs: (0, storageCleanup_1.defaultCacheDirs)([home]),
116
+ isAttached: (ws) => attached.has(ws),
117
+ previewActivity: () => previewActivity,
118
+ run: execRunner,
119
+ ...over,
120
+ };
121
+ }
122
+ beforeEach(() => {
123
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'stc05-cleanup-'));
124
+ repos = path.join(root, 'repos');
125
+ previews = path.join(root, 'previews');
126
+ home = path.join(root, 'home');
127
+ fs.mkdirSync(repos);
128
+ attached = new Set();
129
+ previewActivity = { opInFlight: false, activeWorkspaces: [] };
130
+ seedRemote();
131
+ });
132
+ afterEach(() => {
133
+ fs.rmSync(root, { recursive: true, force: true });
134
+ });
135
+ describe('storage cleanup — caches', () => {
136
+ it('deletes npm / uv / pip caches in the home dir', async () => {
137
+ writeBlob(path.join(home, '.npm', '_cacache', 'content-v2', 'sha512', 'aa'), 50 * KB);
138
+ writeBlob(path.join(home, '.cache', 'pip', 'http', 'x'), 25 * KB);
139
+ writeBlob(path.join(home, '.cache', 'uv', 'wheels', 'y'), 25 * KB);
140
+ writeBlob(path.join(home, '.npmrc'), 1 * KB); // config, not cache
141
+ const cleaner = (0, storageCleanup_1.createStorageCleaner)(deps());
142
+ const report = await cleaner.measure();
143
+ expect(report.byCategory.caches).toBeGreaterThanOrEqual(100 * KB);
144
+ const result = await cleaner.clean();
145
+ expect(fs.existsSync(path.join(home, '.npm', '_cacache'))).toBe(false);
146
+ expect(fs.existsSync(path.join(home, '.cache', 'pip'))).toBe(false);
147
+ expect(fs.existsSync(path.join(home, '.cache', 'uv'))).toBe(false);
148
+ expect(fs.existsSync(path.join(home, '.npmrc'))).toBe(true);
149
+ expect(result.byCategory.caches).toBe(report.byCategory.caches);
150
+ });
151
+ });
152
+ describe('storage cleanup — build output in checkouts', () => {
153
+ it('a clean, pushed checkout loses its gitignored build output and keeps source, .env and other ignored files', async () => {
154
+ const dir = checkout('ws-clean');
155
+ const result = await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
156
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(false);
157
+ expect(fs.existsSync(path.join(dir, '.next'))).toBe(false);
158
+ expect(fs.existsSync(path.join(dir, 'dist'))).toBe(false);
159
+ expect(fs.existsSync(path.join(dir, 'packages', 'ui', 'node_modules'))).toBe(false);
160
+ expect(fs.readFileSync(path.join(dir, 'index.ts'), 'utf8')).toBe('export {};\n');
161
+ expect(fs.readFileSync(path.join(dir, '.env'), 'utf8')).toBe('API_KEY=only-copy\n');
162
+ expect(fs.existsSync(path.join(dir, 'secrets', 'key.pem'))).toBe(true);
163
+ expect(fs.existsSync(path.join(dir, '.git'))).toBe(true);
164
+ expect(result.byCategory.buildOutput).toBeGreaterThanOrEqual(100 * KB);
165
+ });
166
+ it('never touches a checkout with uncommitted work', async () => {
167
+ const dir = checkout('ws-dirty');
168
+ fs.writeFileSync(path.join(dir, 'index.ts'), 'export const edited = 1;\n');
169
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
170
+ expect(fs.existsSync(path.join(dir, 'node_modules', 'left-pad', 'index.js'))).toBe(true);
171
+ expect(fs.existsSync(path.join(dir, 'dist', 'bundle.js'))).toBe(true);
172
+ });
173
+ it('never touches a checkout with an untracked (unsaved) file', async () => {
174
+ const dir = checkout('ws-untracked');
175
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'draft\n');
176
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
177
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
178
+ expect(fs.existsSync(path.join(dir, 'notes.md'))).toBe(true);
179
+ });
180
+ it('never touches a checkout with unpushed commits', async () => {
181
+ const dir = checkout('ws-unpushed');
182
+ fs.writeFileSync(path.join(dir, 'a.ts'), '1\n');
183
+ git(dir, 'add', 'a.ts');
184
+ git(dir, 'commit', '--quiet', '-m', 'local only');
185
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
186
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
187
+ expect(fs.existsSync(path.join(dir, '.next'))).toBe(true);
188
+ });
189
+ it('never touches a checkout with a stash', async () => {
190
+ const dir = checkout('ws-stashed');
191
+ fs.writeFileSync(path.join(dir, 'index.ts'), 'export const wip = 1;\n');
192
+ git(dir, 'stash', '--quiet');
193
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
194
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
195
+ expect(git(dir, 'stash', 'list').trim().length).toBeGreaterThan(0);
196
+ });
197
+ it('never touches a checkout with an attached session', async () => {
198
+ const dir = checkout('ws-attached');
199
+ attached.add('ws-attached');
200
+ const cleaner = (0, storageCleanup_1.createStorageCleaner)(deps());
201
+ expect((await cleaner.measure()).byCategory.buildOutput).toBe(0);
202
+ await cleaner.clean();
203
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
204
+ });
205
+ it('re-checks attachment immediately before deleting — a session that attaches mid-cleanup aborts it', async () => {
206
+ const dir = checkout('ws-late-attach');
207
+ const realDeps = deps();
208
+ let measured = 0;
209
+ const cleaner = (0, storageCleanup_1.createStorageCleaner)({
210
+ ...realDeps,
211
+ // The cleaner measures each target right before removing it; attach then.
212
+ sizeBytes: async (p) => {
213
+ if (p.startsWith(dir)) {
214
+ measured += 1;
215
+ attached.add('ws-late-attach');
216
+ }
217
+ return diskBytes(p);
218
+ },
219
+ });
220
+ const result = await cleaner.clean();
221
+ expect(measured).toBeGreaterThan(0);
222
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
223
+ expect(fs.existsSync(path.join(dir, 'dist'))).toBe(true);
224
+ expect(result.byCategory.buildOutput).toBe(0);
225
+ });
226
+ it('re-checks dirtiness immediately before deleting — work written mid-cleanup keeps the build output', async () => {
227
+ const dir = checkout('ws-late-dirty');
228
+ const cleaner = (0, storageCleanup_1.createStorageCleaner)({
229
+ ...deps(),
230
+ sizeBytes: async (p) => {
231
+ if (p.startsWith(dir))
232
+ fs.writeFileSync(path.join(dir, 'index.ts'), 'export const late = 1;\n');
233
+ return diskBytes(p);
234
+ },
235
+ });
236
+ await cleaner.clean();
237
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
238
+ });
239
+ });
240
+ describe('storage cleanup — checkouts the user already removed', () => {
241
+ it('deletes a leftover directory with no repo and no files outside build output', async () => {
242
+ const orphan = path.join(repos, 'ws-removed');
243
+ writeBlob(path.join(orphan, 'node_modules', 'react', 'index.js'), 30 * KB);
244
+ fs.mkdirSync(path.join(orphan, 'src', 'empty'), { recursive: true });
245
+ const result = await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
246
+ expect(fs.existsSync(orphan)).toBe(false);
247
+ expect(result.byCategory.removedCheckouts).toBeGreaterThanOrEqual(30 * KB);
248
+ });
249
+ it('keeps a directory with no repo that still holds a file (could be the only copy)', async () => {
250
+ const notRepo = path.join(repos, 'ws-hand-made');
251
+ writeBlob(path.join(notRepo, 'node_modules', 'x.js'), 10 * KB);
252
+ fs.writeFileSync(path.join(notRepo, 'main.py'), 'print(1)\n');
253
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
254
+ expect(fs.existsSync(path.join(notRepo, 'main.py'))).toBe(true);
255
+ expect(fs.existsSync(path.join(notRepo, 'node_modules', 'x.js'))).toBe(true);
256
+ });
257
+ it('keeps a leftover with a .env', async () => {
258
+ const left = path.join(repos, 'ws-env-left');
259
+ writeBlob(path.join(left, 'node_modules', 'x.js'), 10 * KB);
260
+ fs.writeFileSync(path.join(left, '.env'), 'K=v\n');
261
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
262
+ expect(fs.readFileSync(path.join(left, '.env'), 'utf8')).toBe('K=v\n');
263
+ });
264
+ it('keeps a removed-checkout leftover while a session is attached to it', async () => {
265
+ const orphan = path.join(repos, 'ws-removed-attached');
266
+ writeBlob(path.join(orphan, 'node_modules', 'x.js'), 10 * KB);
267
+ attached.add('ws-removed-attached');
268
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
269
+ expect(fs.existsSync(orphan)).toBe(true);
270
+ });
271
+ });
272
+ describe('storage cleanup — preview slots', () => {
273
+ function previewWs(ws) {
274
+ const dir = path.join(previews, ws);
275
+ writeBlob(path.join(dir, 'a', 'node_modules', 'n.js'), 20 * KB);
276
+ writeBlob(path.join(dir, 'b', '.venv', 'lib', 'p.py'), 20 * KB);
277
+ fs.writeFileSync(path.join(dir, 'preview.log'), 'log\n');
278
+ return dir;
279
+ }
280
+ it('deletes slots of a preview that is not running, and the shared uv cache when nothing runs', async () => {
281
+ const idle = previewWs('ws-idle');
282
+ writeBlob(path.join(previews, '.tools', 'uv-cache', 'wheels', 'w'), 30 * KB);
283
+ writeBlob(path.join(previews, '.tools', 'bin', 'uv'), 5 * KB);
284
+ const result = await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
285
+ expect(fs.existsSync(path.join(idle, 'a'))).toBe(false);
286
+ expect(fs.existsSync(path.join(idle, 'b'))).toBe(false);
287
+ expect(fs.existsSync(path.join(previews, '.tools', 'uv-cache'))).toBe(false);
288
+ expect(fs.existsSync(path.join(previews, '.tools', 'bin', 'uv'))).toBe(true);
289
+ expect(result.byCategory.previews).toBeGreaterThanOrEqual(70 * KB);
290
+ });
291
+ it('keeps the running preview and the shared tools cache while a preview runs', async () => {
292
+ const running = previewWs('ws-running');
293
+ const idle = previewWs('ws-idle');
294
+ writeBlob(path.join(previews, '.tools', 'uv-cache', 'wheels', 'w'), 30 * KB);
295
+ previewActivity = { opInFlight: false, activeWorkspaces: ['ws-running'] };
296
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
297
+ expect(fs.existsSync(path.join(running, 'a', 'node_modules', 'n.js'))).toBe(true);
298
+ expect(fs.existsSync(path.join(running, 'b', '.venv'))).toBe(true);
299
+ expect(fs.existsSync(path.join(previews, '.tools', 'uv-cache', 'wheels', 'w'))).toBe(true);
300
+ expect(fs.existsSync(path.join(idle, 'a'))).toBe(false);
301
+ });
302
+ it('keeps a preview whose server a previous bridge left running (pidfile present)', async () => {
303
+ const left = previewWs('ws-pid');
304
+ fs.writeFileSync(path.join(left, 'preview.pid'), JSON.stringify({ pid: 999999, slot: 'a', sha: 'x' }));
305
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
306
+ expect(fs.existsSync(path.join(left, 'a', 'node_modules', 'n.js'))).toBe(true);
307
+ });
308
+ it('touches no preview at all while a preview build is in flight', async () => {
309
+ const idle = previewWs('ws-idle');
310
+ writeBlob(path.join(previews, '.tools', 'uv-cache', 'w'), 10 * KB);
311
+ previewActivity = { opInFlight: true, activeWorkspaces: [] };
312
+ await (0, storageCleanup_1.createStorageCleaner)(deps()).clean();
313
+ expect(fs.existsSync(path.join(idle, 'a'))).toBe(true);
314
+ expect(fs.existsSync(path.join(previews, '.tools', 'uv-cache', 'w'))).toBe(true);
315
+ });
316
+ });
317
+ describe('storage cleanup — the reclaimable figure', () => {
318
+ it('matches what Clean up frees, and what actually left the disk', async () => {
319
+ checkout('ws-clean');
320
+ const dirty = checkout('ws-dirty');
321
+ fs.writeFileSync(path.join(dirty, 'index.ts'), 'edited\n');
322
+ writeBlob(path.join(repos, 'ws-removed', 'node_modules', 'r.js'), 30 * KB);
323
+ writeBlob(path.join(home, '.npm', '_cacache', 'c'), 40 * KB);
324
+ writeBlob(path.join(previews, 'ws-old', 'a', 'node_modules', 'n.js'), 20 * KB);
325
+ const before = diskBytes(root);
326
+ const cleaner = (0, storageCleanup_1.createStorageCleaner)(deps());
327
+ const report = await cleaner.measure();
328
+ const result = await cleaner.clean();
329
+ const after = diskBytes(root);
330
+ expect(report.reclaimableBytes).toBeGreaterThan(0);
331
+ expect(report.reclaimableBytes).toBe(report.byCategory.caches + report.byCategory.previews + report.byCategory.removedCheckouts + report.byCategory.buildOutput);
332
+ expect(result.freedBytes).toBe(report.reclaimableBytes);
333
+ // ±one directory block per removed target — rounding, not missing data.
334
+ expect(Math.abs(before - after - result.freedBytes)).toBeLessThan(64 * KB);
335
+ // Nothing left to free.
336
+ expect((await cleaner.measure()).reclaimableBytes).toBe(0);
337
+ });
338
+ });
339
+ describe('storage cleanup — automatic cleanup (stc-06) behind a kill switch', () => {
340
+ it('does nothing while the kill switch is off (the default)', async () => {
341
+ writeBlob(path.join(home, '.npm', '_cacache', 'c'), 10 * KB);
342
+ const result = await (0, storageCleanup_1.createStorageCleaner)(deps()).runAutomaticCleanup();
343
+ expect(result).toEqual({ ran: false, reason: 'disabled' });
344
+ expect(fs.existsSync(path.join(home, '.npm', '_cacache', 'c'))).toBe(true);
345
+ });
346
+ it('when on, frees caches and idle previews only — never checkout contents', async () => {
347
+ const dir = checkout('ws-clean');
348
+ writeBlob(path.join(repos, 'ws-removed', 'node_modules', 'r.js'), 10 * KB);
349
+ writeBlob(path.join(home, '.npm', '_cacache', 'c'), 10 * KB);
350
+ writeBlob(path.join(previews, 'ws-old', 'a', 'x'), 10 * KB);
351
+ const result = await (0, storageCleanup_1.createStorageCleaner)({ ...deps(), autoCleanupEnabled: true }).runAutomaticCleanup();
352
+ expect(result.ran).toBe(true);
353
+ expect(fs.existsSync(path.join(home, '.npm', '_cacache'))).toBe(false);
354
+ expect(fs.existsSync(path.join(previews, 'ws-old', 'a'))).toBe(false);
355
+ expect(fs.existsSync(path.join(dir, 'node_modules'))).toBe(true);
356
+ expect(fs.existsSync(path.join(repos, 'ws-removed'))).toBe(true);
357
+ });
358
+ it('the button (clean) works with the kill switch off', async () => {
359
+ writeBlob(path.join(home, '.npm', '_cacache', 'c'), 10 * KB);
360
+ const result = await (0, storageCleanup_1.createStorageCleaner)({ ...deps(), autoCleanupEnabled: false }).clean();
361
+ expect(result.freedBytes).toBeGreaterThan(0);
362
+ expect(fs.existsSync(path.join(home, '.npm', '_cacache'))).toBe(false);
363
+ });
364
+ });
365
+ describe('storage cleanup — one run at a time', () => {
366
+ it('a second clean while one runs is refused as busy, not raced', async () => {
367
+ writeBlob(path.join(home, '.npm', '_cacache', 'c'), 10 * KB);
368
+ const cleaner = (0, storageCleanup_1.createStorageCleaner)(deps());
369
+ const [a, b] = await Promise.all([cleaner.clean(), cleaner.clean()]);
370
+ expect([a.busy, b.busy].filter(Boolean)).toHaveLength(1);
371
+ });
372
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * a9be9720 — a hosted agent can push for as long as its session lives.
8
+ *
9
+ * WENRWA_GIT_TOKEN went onto a PTY's env once, at open, and a GitHub App
10
+ * installation token lives an hour: every agent session older than that held
11
+ * a dead credential ("remote: Invalid username or token") and no retry helped.
12
+ *
13
+ * Now `git-credential-wenrwa get` asks the bridge, over a 0600 unix socket,
14
+ * for a credential for $WENRWA_WORKSPACE_ID. The bridge caches it until shortly
15
+ * before it expires and otherwise asks the relay (`git-token`) for a fresh one.
16
+ * The injected env token stays as the fallback when the bridge cannot answer.
17
+ *
18
+ * Drives the REAL relay client against a fake relay, the REAL socket server,
19
+ * and the REAL shell helper from the box image.
20
+ */
21
+ const child_process_1 = require("child_process");
22
+ const fs_1 = __importDefault(require("fs"));
23
+ const os_1 = __importDefault(require("os"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const ws_1 = require("ws");
26
+ const relayClient_1 = require("../bridge/relayClient");
27
+ const gitTokenBroker_1 = require("../bridge/gitTokenBroker");
28
+ const sessionManager_1 = require("../bridge/sessionManager");
29
+ class FakePty {
30
+ opts;
31
+ constructor(opts) {
32
+ this.opts = opts;
33
+ }
34
+ write() { }
35
+ resize() { }
36
+ kill() { }
37
+ onData() { }
38
+ onExit() { }
39
+ }
40
+ const makeFactory = () => ({ spawn: (o) => new FakePty(o) });
41
+ const HELPER = path_1.default.resolve(__dirname, '../../../infra/terminal-image/git-credential-wenrwa');
42
+ function startFakeRelay() {
43
+ return new Promise((resolve) => {
44
+ const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
45
+ const queue = [];
46
+ const waiters = [];
47
+ wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
48
+ w(ws);
49
+ else
50
+ queue.push(ws); });
51
+ wss.on('listening', () => {
52
+ const addr = wss.address();
53
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
54
+ resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
55
+ });
56
+ });
57
+ }
58
+ function closeRelay(wss) {
59
+ return new Promise((resolve, reject) => {
60
+ for (const client of wss.clients)
61
+ client.terminate();
62
+ wss.close((err) => (err ? reject(err) : resolve()));
63
+ });
64
+ }
65
+ /** The next frame of type `t` the bridge sends (it sends reports too). */
66
+ function nextOfType(ws, t) {
67
+ return new Promise((resolve) => {
68
+ const onMsg = (raw) => {
69
+ const f = JSON.parse(String(raw));
70
+ if (f.t !== t)
71
+ return;
72
+ ws.off('message', onMsg);
73
+ resolve(f);
74
+ };
75
+ ws.on('message', onMsg);
76
+ });
77
+ }
78
+ const inMinutes = (m, from = Date.now()) => new Date(from + m * 60_000).toISOString();
79
+ describe('relay client — requestGitToken (a9be9720)', () => {
80
+ it('asks the relay with a git-token frame and resolves with its answer', async () => {
81
+ const relay = await startFakeRelay();
82
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
83
+ try {
84
+ const ws = await relay.nextConnection();
85
+ ws.send(JSON.stringify({ t: 'noop' })); // the relay accepting us (any frame)
86
+ await new Promise((r) => setTimeout(r, 30));
87
+ const asked = nextOfType(ws, 'git-token');
88
+ const pending = handle.requestGitToken('ws-1');
89
+ const req = await asked;
90
+ expect(req).toEqual({ t: 'git-token', requestId: expect.any(String), workspaceId: 'ws-1' });
91
+ ws.send(JSON.stringify({ t: 'git-token', requestId: req.requestId, workspaceId: 'ws-1', ok: true, token: 'ghs_new', repoFullName: 'acme/w', expiresAt: '2026-10-24T13:00:00.000Z' }));
92
+ await expect(pending).resolves.toEqual({ token: 'ghs_new', repoFullName: 'acme/w', expiresAt: '2026-10-24T13:00:00.000Z' });
93
+ }
94
+ finally {
95
+ handle.close();
96
+ await closeRelay(relay.wss);
97
+ }
98
+ });
99
+ it('a refusal rejects with the relay\'s code and sentence', async () => {
100
+ const relay = await startFakeRelay();
101
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
102
+ try {
103
+ const ws = await relay.nextConnection();
104
+ ws.send(JSON.stringify({ t: 'noop' }));
105
+ await new Promise((r) => setTimeout(r, 30));
106
+ const asked = nextOfType(ws, 'git-token');
107
+ const pending = handle.requestGitToken('ws-1');
108
+ const req = await asked;
109
+ ws.send(JSON.stringify({ t: 'git-token', requestId: req.requestId, ok: false, code: 'WORKSPACE_ACCESS_DENIED', message: 'Not a member of this workspace' }));
110
+ await expect(pending).rejects.toMatchObject({ code: 'WORKSPACE_ACCESS_DENIED', message: 'Not a member of this workspace' });
111
+ }
112
+ finally {
113
+ handle.close();
114
+ await closeRelay(relay.wss);
115
+ }
116
+ });
117
+ it('no relay connection → rejects at once (the helper then falls back), never waits out a timeout', async () => {
118
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: 'ws://127.0.0.1:9', relayToken: 'tok', sessions: new sessionManager_1.SessionManager(makeFactory()) });
119
+ try {
120
+ const t0 = Date.now();
121
+ await expect(handle.requestGitToken('ws-1')).rejects.toMatchObject({ code: 'OFFLINE' });
122
+ expect(Date.now() - t0).toBeLessThan(500);
123
+ }
124
+ finally {
125
+ handle.close();
126
+ }
127
+ });
128
+ });
129
+ describe('GitTokenBroker — one fresh credential per workspace, cached until shortly before it expires', () => {
130
+ it('reuses a credential while it has more than 5 minutes left, then asks again', async () => {
131
+ let now = Date.parse('2026-10-24T12:00:00.000Z');
132
+ let calls = 0;
133
+ const fetch = jest.fn(async () => ({ token: `t${++calls}`, repoFullName: 'acme/w', expiresAt: inMinutes(15, now) }));
134
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch, () => now);
135
+ expect((await broker.get('ws-1')).token).toBe('t1');
136
+ now += 9 * 60_000; // 6 minutes left
137
+ expect((await broker.get('ws-1')).token).toBe('t1');
138
+ now += 2 * 60_000; // 4 minutes left — refresh
139
+ expect((await broker.get('ws-1')).token).toBe('t2');
140
+ expect(fetch).toHaveBeenCalledTimes(2);
141
+ });
142
+ it('workspaces never share a credential', async () => {
143
+ const fetch = jest.fn(async (ws) => ({ token: `tok-${ws}`, repoFullName: ws, expiresAt: inMinutes(15) }));
144
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
145
+ expect((await broker.get('ws-A')).token).toBe('tok-ws-A');
146
+ expect((await broker.get('ws-B')).token).toBe('tok-ws-B');
147
+ });
148
+ it('a burst of git calls makes ONE request', async () => {
149
+ let release;
150
+ const gate = new Promise((r) => { release = r; });
151
+ const fetch = jest.fn(async () => { await gate; return { token: 't', repoFullName: 'r', expiresAt: inMinutes(15) }; });
152
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
153
+ const all = Promise.all([broker.get('ws-1'), broker.get('ws-1'), broker.get('ws-1')]);
154
+ release();
155
+ await all;
156
+ expect(fetch).toHaveBeenCalledTimes(1);
157
+ });
158
+ it('a failure is not cached: the next call asks again', async () => {
159
+ const fetch = jest
160
+ .fn()
161
+ .mockRejectedValueOnce(Object.assign(new Error('offline'), { code: 'OFFLINE' }))
162
+ .mockResolvedValueOnce({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) });
163
+ const broker = new gitTokenBroker_1.GitTokenBroker(fetch);
164
+ await expect(broker.get('ws-1')).rejects.toMatchObject({ code: 'OFFLINE' });
165
+ expect((await broker.get('ws-1')).token).toBe('t');
166
+ });
167
+ });
168
+ describe('git token socket + the box\'s git-credential-wenrwa helper', () => {
169
+ let dir;
170
+ let sock;
171
+ let server = null;
172
+ beforeEach(() => {
173
+ // Short path: unix socket paths are limited to ~104 bytes on macOS.
174
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'gtk-'));
175
+ sock = path_1.default.join(dir, 'git.sock');
176
+ });
177
+ afterEach(async () => {
178
+ await server?.close();
179
+ server = null;
180
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
181
+ });
182
+ const runHelper = (env) => new Promise((resolve) => {
183
+ const child = (0, child_process_1.execFile)('sh', [HELPER, 'get'], { env: { PATH: process.env.PATH ?? '', ...env } }, (err, stdout, stderr) => {
184
+ resolve({ code: err ? err.code ?? 1 : 0, stdout, stderr });
185
+ });
186
+ child.stdin?.end('protocol=https\nhost=github.com\n\n');
187
+ });
188
+ it('the socket is owner-only (0600)', async () => {
189
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(async () => ({ token: 't', repoFullName: 'r', expiresAt: inMinutes(15) })) });
190
+ expect(fs_1.default.statSync(sock).mode & 0o777).toBe(0o600);
191
+ });
192
+ it('the helper prints the bridge\'s FRESH credential, not the stale one on its env', async () => {
193
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({
194
+ path: sock,
195
+ broker: new gitTokenBroker_1.GitTokenBroker(async (ws) => ({ token: `fresh-for-${ws}`, repoFullName: 'acme/w', expiresAt: inMinutes(15) })),
196
+ });
197
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'stale' });
198
+ expect(out.code).toBe(0);
199
+ expect(out.stdout).toBe('username=x-access-token\npassword=fresh-for-ws-1\n');
200
+ });
201
+ it('bridge cannot mint (offline, refused) → the helper falls back to the injected token', async () => {
202
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({
203
+ path: sock,
204
+ broker: new gitTokenBroker_1.GitTokenBroker(async () => { throw Object.assign(new Error('offline'), { code: 'OFFLINE' }); }),
205
+ });
206
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'injected' });
207
+ expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
208
+ });
209
+ it('no bridge socket at all (an older bridge) → the injected token, exactly as before', async () => {
210
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: path_1.default.join(dir, 'missing.sock'), WENRWA_WORKSPACE_ID: 'ws-1', WENRWA_GIT_TOKEN: 'injected' });
211
+ expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
212
+ });
213
+ it('a request without a workspace is refused (the bridge never guesses one)', async () => {
214
+ const fetch = jest.fn();
215
+ server = await (0, gitTokenBroker_1.startGitTokenSocket)({ path: sock, broker: new gitTokenBroker_1.GitTokenBroker(fetch) });
216
+ const out = await runHelper({ WENRWA_GIT_TOKEN_SOCKET: sock, WENRWA_GIT_TOKEN: 'injected' });
217
+ expect(out.stdout).toBe('username=x-access-token\npassword=injected\n');
218
+ expect(fetch).not.toHaveBeenCalled();
219
+ });
220
+ });
221
+ describe('assembleSessionEnv — a PTY knows where to ask', () => {
222
+ it('carries the socket path beside the injected token', () => {
223
+ const env = (0, relayClient_1.assembleSessionEnv)({
224
+ workspaceId: 'ws-1',
225
+ gitCredential: { token: 't', repoFullName: 'acme/w', expiresAt: inMinutes(15) },
226
+ gitTokenSocket: '/home/wenrwa/.wenrwa/git.sock',
227
+ });
228
+ expect(env.WENRWA_GIT_TOKEN_SOCKET).toBe('/home/wenrwa/.wenrwa/git.sock');
229
+ expect(env.WENRWA_GIT_TOKEN).toBe('t');
230
+ });
231
+ });
@@ -43,6 +43,7 @@
43
43
  */
44
44
  /** Frame-level discriminator so a pane can render the refusal as a sentence
45
45
  * rather than falling back to a session that looks normal but isn't. */
46
+ import type { GrowQuote } from './storageQuotaPreflight';
46
47
  export declare const CLONE_SPACE_PREFLIGHT_REFUSED = "CLONE_SPACE_PREFLIGHT_REFUSED";
47
48
  export type ClonePreflightVerdict = {
48
49
  kind: 'fits';
@@ -83,6 +84,8 @@ export declare function clonePreflightMessage(verdict: ClonePreflightVerdict): s
83
84
  */
84
85
  export declare class ClonePreflightRefusalError extends Error {
85
86
  readonly code: string;
87
+ /** stc-04 — the growth price, sent to the pane beside code/message. */
88
+ readonly quote?: GrowQuote;
86
89
  /**
87
90
  * hst-05 — `code` is now a constructor param (default unchanged:
88
91
  * CLONE_SPACE_PREFLIGHT_REFUSED), so this same transport error can also
@@ -90,6 +93,6 @@ export declare class ClonePreflightRefusalError extends Error {
90
93
  * the two refusals sharing a code. Every pre-hst-05 call site passes only
91
94
  * `message` and gets byte-identical behaviour.
92
95
  */
93
- constructor(message: string, code?: string);
96
+ constructor(message: string, code?: string, quote?: GrowQuote);
94
97
  }
95
98
  export declare function isClonePreflightRefusal(err: unknown): err is ClonePreflightRefusalError;
@@ -47,8 +47,6 @@ exports.ClonePreflightRefusalError = exports.CLONE_SPACE_PREFLIGHT_REFUSED = voi
47
47
  exports.checkClonePreflight = checkClonePreflight;
48
48
  exports.clonePreflightMessage = clonePreflightMessage;
49
49
  exports.isClonePreflightRefusal = isClonePreflightRefusal;
50
- /** Frame-level discriminator so a pane can render the refusal as a sentence
51
- * rather than falling back to a session that looks normal but isn't. */
52
50
  exports.CLONE_SPACE_PREFLIGHT_REFUSED = 'CLONE_SPACE_PREFLIGHT_REFUSED';
53
51
  /**
54
52
  * The core decision. `availableMb` of `null` means free space itself is
@@ -105,6 +103,8 @@ function clonePreflightMessage(verdict) {
105
103
  */
106
104
  class ClonePreflightRefusalError extends Error {
107
105
  code;
106
+ /** stc-04 — the growth price, sent to the pane beside code/message. */
107
+ quote;
108
108
  /**
109
109
  * hst-05 — `code` is now a constructor param (default unchanged:
110
110
  * CLONE_SPACE_PREFLIGHT_REFUSED), so this same transport error can also
@@ -112,10 +112,12 @@ class ClonePreflightRefusalError extends Error {
112
112
  * the two refusals sharing a code. Every pre-hst-05 call site passes only
113
113
  * `message` and gets byte-identical behaviour.
114
114
  */
115
- constructor(message, code = exports.CLONE_SPACE_PREFLIGHT_REFUSED) {
115
+ constructor(message, code = exports.CLONE_SPACE_PREFLIGHT_REFUSED, quote) {
116
116
  super(message);
117
117
  this.name = 'ClonePreflightRefusalError';
118
118
  this.code = code;
119
+ if (quote)
120
+ this.quote = quote;
119
121
  }
120
122
  }
121
123
  exports.ClonePreflightRefusalError = ClonePreflightRefusalError;