@wenrwa/marketplace-cli 0.8.10 → 0.8.11

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 @@
1
+ export {};
@@ -0,0 +1,287 @@
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
+ * tp-21 — Team preview runs Python apps directly (no package.json launcher).
38
+ *
39
+ * Pure: planPreview decides the steps and start command from the checkout.
40
+ * Real: a bare remote holding a Python app, run through PreviewRunner with the
41
+ * machine's own uv + python3 (skipped when this machine has no uv). Every
42
+ * server is stopped by exact PID in teardown (runner.shutdown).
43
+ */
44
+ const child_process_1 = require("child_process");
45
+ const fs = __importStar(require("fs"));
46
+ const http = __importStar(require("http"));
47
+ const net = __importStar(require("net"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ const previewCheckout_1 = require("../bridge/previewCheckout");
51
+ jest.setTimeout(180_000);
52
+ function checkout(files) {
53
+ return {
54
+ has: (n) => Object.prototype.hasOwnProperty.call(files, n),
55
+ read: (n) => (Object.prototype.hasOwnProperty.call(files, n) ? files[n] : null),
56
+ };
57
+ }
58
+ const CTX = { slotDir: '/data/previews/ws/a', toolsDir: '/data/previews/.tools', uv: '/usr/bin/uv', basePath: '/usr/bin:/bin' };
59
+ const PROJECT = '[project]\nname = "app"\nversion = "0.1.0"\ndependencies = []\n';
60
+ describe('planPreview (tp-21)', () => {
61
+ it('package.json keeps the Node path even with Python files beside it', () => {
62
+ const r = (0, previewCheckout_1.planPreview)(checkout({
63
+ 'package.json': JSON.stringify({ scripts: { build: 'sh preview/build.sh', start: 'sh preview/start.sh' } }),
64
+ 'pyproject.toml': `${PROJECT}[tool.hiveop.preview]\nstart = "python app.py"\n`,
65
+ 'uv.lock': '',
66
+ }), 'built', CTX);
67
+ expect(r).toEqual({ ok: true, plan: { kind: 'node', steps: [['npm', ['install']], ['npm', ['run', 'build']]], start: ['npm', ['run', 'start']], env: {} } });
68
+ });
69
+ it('package.json without the script is still no_start_script', () => {
70
+ expect((0, previewCheckout_1.planPreview)(checkout({ 'package.json': '{"scripts":{}}' }), 'built', CTX)).toMatchObject({ ok: false, reason: 'no_start_script' });
71
+ });
72
+ it('no package.json and no Python project is no_package_json', () => {
73
+ expect((0, previewCheckout_1.planPreview)(checkout({ 'README.md': 'hi', Procfile: 'web: python app.py' }), 'built', CTX))
74
+ .toMatchObject({ ok: false, reason: 'no_package_json' });
75
+ });
76
+ it('uv.lock → uv sync --frozen, and the pyproject start command runs through sh', () => {
77
+ const r = (0, previewCheckout_1.planPreview)(checkout({
78
+ 'pyproject.toml': `${PROJECT}\n[tool.hiveop.preview]\nstart = "uvicorn app:app --host $HOST --port $PORT"\nbuild = 'python manage.py collectstatic --noinput'\n`,
79
+ 'uv.lock': 'version = 1',
80
+ }), 'built', CTX);
81
+ expect(r.ok && r.plan).toEqual({
82
+ kind: 'python',
83
+ steps: [
84
+ ['/usr/bin/uv', ['sync', '--frozen', '--no-dev']],
85
+ ['sh', ['-c', 'python manage.py collectstatic --noinput']],
86
+ ],
87
+ start: ['sh', ['-c', 'uvicorn app:app --host $HOST --port $PORT']],
88
+ env: {
89
+ PATH: '/data/previews/ws/a/.venv/bin:/data/previews/.tools/bin:/usr/bin:/bin',
90
+ VIRTUAL_ENV: '/data/previews/ws/a/.venv',
91
+ UV_PROJECT_ENVIRONMENT: '/data/previews/ws/a/.venv',
92
+ UV_CACHE_DIR: '/data/previews/.tools/uv-cache',
93
+ UV_PYTHON_INSTALL_DIR: '/data/previews/.tools/python',
94
+ UV_PYTHON_PREFERENCE: 'system',
95
+ PYTHONUNBUFFERED: '1',
96
+ },
97
+ });
98
+ });
99
+ it('dev mode skips the pyproject build step', () => {
100
+ const r = (0, previewCheckout_1.planPreview)(checkout({ 'pyproject.toml': `${PROJECT}[tool.hiveop.preview]\nstart = "python app.py"\nbuild = "make"\n` }), 'dev', CTX);
101
+ expect(r.ok && r.plan.steps).toEqual([['/usr/bin/uv', ['sync', '--no-dev']]]);
102
+ });
103
+ it('requirements.txt → uv venv + uv pip install, started from the Procfile web line', () => {
104
+ const r = (0, previewCheckout_1.planPreview)(checkout({ 'requirements.txt': 'flask\n', Procfile: 'worker: python jobs.py\nweb: gunicorn app:app -b 127.0.0.1:$PORT\n' }), 'built', CTX);
105
+ expect(r.ok && r.plan.steps).toEqual([
106
+ ['/usr/bin/uv', ['venv', '--allow-existing', '/data/previews/ws/a/.venv']],
107
+ ['/usr/bin/uv', ['pip', 'install', '-r', 'requirements.txt']],
108
+ ]);
109
+ expect(r.ok && r.plan.start).toEqual(['sh', ['-c', 'gunicorn app:app -b 127.0.0.1:$PORT']]);
110
+ });
111
+ it('a tool-config-only pyproject beside requirements.txt uses the requirements path', () => {
112
+ const r = (0, previewCheckout_1.planPreview)(checkout({ 'pyproject.toml': '[tool.ruff]\nline-length = 100\n', 'requirements.txt': '', Procfile: 'web: python app.py' }), 'built', CTX);
113
+ expect(r.ok && r.plan.steps.map(([, a]) => a[0])).toEqual(['venv', 'pip']);
114
+ });
115
+ it('pyproject start wins over the Procfile', () => {
116
+ const r = (0, previewCheckout_1.planPreview)(checkout({ 'pyproject.toml': `${PROJECT}[tool.hiveop.preview]\nstart = "python a.py"\n`, Procfile: 'web: python b.py' }), 'built', CTX);
117
+ expect(r.ok && r.plan.start).toEqual(['sh', ['-c', 'python a.py']]);
118
+ });
119
+ it('no start command is refused no_start_command, never guessed', () => {
120
+ const r = (0, previewCheckout_1.planPreview)(checkout({ 'pyproject.toml': PROJECT, 'uv.lock': '', 'main.py': 'print(1)', Procfile: 'worker: x' }), 'built', CTX);
121
+ expect(r).toMatchObject({ ok: false, reason: 'no_start_command' });
122
+ expect(!r.ok && r.message).toMatch(/\[tool\.hiveop\.preview\]/);
123
+ });
124
+ it('no uv on the box → install the pinned uv into the persistent tools dir first, and run that one', () => {
125
+ const r = (0, previewCheckout_1.planPreview)(checkout({ 'requirements.txt': '', Procfile: 'web: python app.py' }), 'built', { ...CTX, uv: null });
126
+ expect(r.ok && r.plan.steps[0]).toEqual(['sh', ['-c',
127
+ `curl -LsSf https://astral.sh/uv/${previewCheckout_1.PREVIEW_UV_VERSION}/install.sh | env UV_INSTALL_DIR="/data/previews/.tools/bin" UV_NO_MODIFY_PATH=1 sh`]]);
128
+ expect(r.ok && r.plan.steps[1][0]).toBe('/data/previews/.tools/bin/uv');
129
+ });
130
+ });
131
+ describe('pyproject / Procfile readers (tp-21)', () => {
132
+ it('reads only [tool.hiveop.preview] string keys', () => {
133
+ const toml = [
134
+ '[project]', 'start = "not this"',
135
+ '[tool.hiveop.preview] # preview', 'start = "python -m app --msg \\"hi\\"" # trailing', "build = 'make all'", 'port = 1',
136
+ '[tool.other]', 'start = "nor this"',
137
+ ].join('\n');
138
+ expect((0, previewCheckout_1.readHiveopPreviewTable)(toml)).toEqual({ start: 'python -m app --msg "hi"', build: 'make all' });
139
+ expect((0, previewCheckout_1.readHiveopPreviewTable)(PROJECT)).toEqual({});
140
+ });
141
+ it('procfileWeb takes the web line only', () => {
142
+ expect((0, previewCheckout_1.procfileWeb)('release: migrate\nweb: python app.py \n')).toBe('python app.py');
143
+ expect((0, previewCheckout_1.procfileWeb)('worker: x\n')).toBeNull();
144
+ });
145
+ it('findUv: PATH, then ~/.local/bin, then the tools dir', () => {
146
+ const exec = (set) => (p) => set.includes(p);
147
+ expect((0, previewCheckout_1.findUv)('/a:/b', '/home/w', '/t', exec(['/b/uv', '/home/w/.local/bin/uv']))).toBe('/b/uv');
148
+ expect((0, previewCheckout_1.findUv)('/a', '/home/w', '/t', exec(['/home/w/.local/bin/uv', '/t/bin/uv']))).toBe('/home/w/.local/bin/uv');
149
+ expect((0, previewCheckout_1.findUv)('/a', '/home/w', '/t', exec(['/t/bin/uv']))).toBe('/t/bin/uv');
150
+ expect((0, previewCheckout_1.findUv)('/a', '/home/w', '/t', exec([]))).toBeNull();
151
+ });
152
+ });
153
+ // ── real git + real uv + real python3 ────────────────────────────────────────
154
+ const HAS_UV = !!(0, previewCheckout_1.findUv)(process.env.PATH, process.env.HOME, '/nonexistent');
155
+ const realIt = HAS_UV ? it : it.skip;
156
+ function git(cwd, ...args) {
157
+ return (0, child_process_1.execFileSync)('git', args, {
158
+ cwd, encoding: 'utf8',
159
+ env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' },
160
+ });
161
+ }
162
+ function freePort() {
163
+ return new Promise((resolve) => {
164
+ const srv = net.createServer();
165
+ srv.listen(0, '127.0.0.1', () => {
166
+ const port = srv.address().port;
167
+ srv.close(() => resolve(port));
168
+ });
169
+ });
170
+ }
171
+ function get(port) {
172
+ return new Promise((resolve, reject) => {
173
+ http.get({ host: '127.0.0.1', port, path: '/' }, (res) => {
174
+ let body = '';
175
+ res.on('data', (c) => (body += c));
176
+ res.on('end', () => resolve(body));
177
+ }).on('error', reject);
178
+ });
179
+ }
180
+ // Standard library only: no network needed for an empty dependency set.
181
+ const SERVER_PY = `
182
+ import json, os, sys
183
+ from http.server import BaseHTTPRequestHandler, HTTPServer
184
+ class H(BaseHTTPRequestHandler):
185
+ def do_GET(self):
186
+ body = json.dumps({
187
+ "message": open("message.txt").read().strip(),
188
+ "secret": os.environ.get("OWNER_SECRET"),
189
+ "bridgeToken": os.environ.get("WENRWA_CONNECT_TOKEN"),
190
+ "inVenv": sys.prefix != sys.base_prefix,
191
+ "port": os.environ.get("PORT"),
192
+ }).encode()
193
+ self.send_response(200); self.end_headers(); self.wfile.write(body)
194
+ def log_message(self, *a): pass
195
+ HTTPServer((os.environ.get("BIND", os.environ["HOST"]), int(os.environ["PORT"])), H).serve_forever()
196
+ `;
197
+ describe('PreviewRunner (tp-21) — a Python app, real uv', () => {
198
+ let root;
199
+ let remote;
200
+ let author;
201
+ let port;
202
+ let runner;
203
+ let previews;
204
+ function push(files, msg) {
205
+ for (const [name, content] of Object.entries(files)) {
206
+ const p = path.join(author, name);
207
+ if (content === null)
208
+ fs.rmSync(p, { force: true });
209
+ else
210
+ fs.writeFileSync(p, content);
211
+ }
212
+ git(author, 'add', '-A');
213
+ git(author, 'commit', '-m', msg);
214
+ git(author, 'push', 'origin', 'main');
215
+ return git(author, 'rev-parse', 'HEAD').trim();
216
+ }
217
+ beforeEach(async () => {
218
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'tp21-'));
219
+ remote = path.join(root, 'remote.git');
220
+ author = path.join(root, 'author');
221
+ previews = path.join(root, 'previews');
222
+ git(root, 'init', '--bare', '-b', 'main', remote);
223
+ fs.mkdirSync(author);
224
+ git(author, 'init', '-b', 'main');
225
+ git(author, 'remote', 'add', 'origin', remote);
226
+ fs.writeFileSync(path.join(author, '.gitignore'), '.env\n');
227
+ fs.writeFileSync(path.join(author, 'server.py'), SERVER_PY);
228
+ fs.writeFileSync(path.join(author, 'message.txt'), 'py v1\n');
229
+ fs.writeFileSync(path.join(author, 'requirements.txt'), '# stdlib only\n');
230
+ fs.writeFileSync(path.join(author, 'Procfile'), 'web: python server.py\n');
231
+ git(author, 'add', '-A');
232
+ git(author, 'commit', '-m', 'v1');
233
+ git(author, 'push', 'origin', 'main');
234
+ const wc = path.join(root, 'repos', 'ws-1');
235
+ fs.mkdirSync(path.dirname(wc), { recursive: true });
236
+ git(root, 'clone', remote, wc);
237
+ fs.writeFileSync(path.join(wc, '.env'), 'OWNER_SECRET=sk_live_owner_value\n');
238
+ port = await freePort();
239
+ runner = new previewCheckout_1.PreviewRunner({
240
+ previewsBaseDir: previews,
241
+ workingCopyDir: (ws) => path.join(root, 'repos', ws),
242
+ port,
243
+ baseEnv: { ...process.env, WENRWA_CONNECT_TOKEN: 'bridge-connect-token' },
244
+ readyTimeoutMs: { built: 20_000, dev: 20_000 },
245
+ probeIntervalMs: 100,
246
+ killGraceMs: 1_000,
247
+ });
248
+ });
249
+ afterEach(async () => {
250
+ await runner.shutdown();
251
+ fs.rmSync(root, { recursive: true, force: true });
252
+ });
253
+ realIt('requirements.txt + Procfile: serves from its venv with the owner .env and no bridge secrets; uv cache on the tools dir', async () => {
254
+ const head = git(author, 'rev-parse', 'HEAD').trim();
255
+ const r = await runner.start({ workspaceId: 'ws-1', repoUrl: remote });
256
+ expect(r).toMatchObject({ status: 'running', sha: head, runningSha: head });
257
+ expect(JSON.parse(await get(port))).toEqual({
258
+ message: 'py v1', secret: 'sk_live_owner_value', bridgeToken: null, inVenv: true, port: String(port),
259
+ });
260
+ expect(fs.existsSync(path.join(previews, '.tools', 'uv-cache'))).toBe(true);
261
+ });
262
+ realIt('pyproject [tool.hiveop.preview] start, uv sync; a start that dies on update keeps the old Python version serving', async () => {
263
+ const v2 = push({
264
+ 'requirements.txt': null, Procfile: null,
265
+ 'pyproject.toml': `${PROJECT}requires-python = ">=3.8"\n\n[tool.hiveop.preview]\nstart = "python server.py"\n`,
266
+ 'message.txt': 'py v2 pyproject\n',
267
+ }, 'pyproject');
268
+ expect(await runner.start({ workspaceId: 'ws-1', repoUrl: remote })).toMatchObject({ status: 'running', runningSha: v2 });
269
+ expect(JSON.parse(await get(port))).toMatchObject({ message: 'py v2 pyproject', inVenv: true });
270
+ const dead = push({ 'pyproject.toml': `${PROJECT}\n[tool.hiveop.preview]\nstart = "python -c 'raise SystemExit(3)'"\n` }, 'dies');
271
+ const r = await runner.update({ workspaceId: 'ws-1', repoUrl: remote });
272
+ expect(r).toMatchObject({ status: 'start_failed', sha: dead, runningSha: v2 });
273
+ expect(JSON.parse(await get(port))).toMatchObject({ message: 'py v2 pyproject', inVenv: true });
274
+ });
275
+ realIt('no start command → failed / no_start_command, nothing started', async () => {
276
+ push({ Procfile: null }, 'no procfile');
277
+ const r = await runner.start({ workspaceId: 'ws-1', repoUrl: remote });
278
+ expect(r).toMatchObject({ status: 'failed', reason: 'no_start_command' });
279
+ await expect(get(port)).rejects.toThrow();
280
+ });
281
+ realIt('SECURITY: a Python app binding every interface is refused unsupported_bind', async () => {
282
+ push({ Procfile: 'web: BIND=0.0.0.0 python server.py\n' }, 'wild');
283
+ const r = await runner.start({ workspaceId: 'ws-1', repoUrl: remote });
284
+ expect(r).toMatchObject({ status: 'failed', reason: 'unsupported_bind' });
285
+ await expect(get(port)).rejects.toThrow();
286
+ });
287
+ });
@@ -34,6 +34,8 @@ describe('fetchStorageQuotaFact', () => {
34
34
  additionalGb: 5,
35
35
  suggestedTier: null,
36
36
  suggestedCapGb: null,
37
+ tierName: null,
38
+ suggestedTierName: null,
37
39
  });
38
40
  });
39
41
  it('a blocked (409 STORAGE_QUOTA_EXCEEDED) response resolves the full blocked fact from the error body', async () => {
@@ -68,8 +70,38 @@ describe('fetchStorageQuotaFact', () => {
68
70
  additionalGb: 10,
69
71
  suggestedTier: 'pro_plus',
70
72
  suggestedCapGb: 50,
73
+ tierName: null,
74
+ suggestedTierName: null,
71
75
  });
72
76
  });
77
+ it("reads the backend's plan display names when present", async () => {
78
+ const fetchImpl = fakeFetch(() => ({
79
+ ok: false,
80
+ status: 409,
81
+ body: {
82
+ success: false,
83
+ error: {
84
+ code: 'STORAGE_QUOTA_EXCEEDED',
85
+ tier: 'payg',
86
+ tierName: 'Pay-as-you-go',
87
+ capGb: 2,
88
+ usedGb: 4,
89
+ additionalGb: 0.023,
90
+ suggestedTier: 'pro',
91
+ suggestedTierName: 'Pro',
92
+ suggestedCapGb: 25,
93
+ },
94
+ },
95
+ }));
96
+ const fact = await (0, storageQuotaFetch_1.fetchStorageQuotaFact)({
97
+ apiUrl: 'https://api.test',
98
+ connectToken: 'tok-1',
99
+ usedGb: 3.96,
100
+ additionalGb: 0.0232,
101
+ fetchImpl,
102
+ });
103
+ expect(fact).toMatchObject({ tier: 'payg', tierName: 'Pay-as-you-go', suggestedTier: 'pro', suggestedTierName: 'Pro' });
104
+ });
73
105
  it('sends the Connect Token as a bearer header and the usage as query params', async () => {
74
106
  let seenUrl = '';
75
107
  let seenHeaders = {};
@@ -64,6 +64,55 @@ describe('storageQuotaPreflightMessage — pure message builder', () => {
64
64
  expect(msg.toLowerCase()).not.toMatch(/free up|reclaim/);
65
65
  });
66
66
  });
67
+ describe('storageQuotaPreflightMessage — human numbers and plan names (owner report 2026-10-24)', () => {
68
+ const OWNER_FACT = {
69
+ allowed: false,
70
+ tier: 'payg',
71
+ tierName: 'Pay-as-you-go',
72
+ capGb: 2,
73
+ usedGb: 3.9614562988281125,
74
+ additionalGb: 0.023197174072265625,
75
+ suggestedTier: 'pro',
76
+ suggestedTierName: 'Pro',
77
+ suggestedCapGb: 25,
78
+ };
79
+ // Pinned copy of the arena's lib/storageRefusal.ts roundRefusalNumbers —
80
+ // the pane still runs it over this text, so it must leave it intact.
81
+ const arenaRound = (m) => m.replace(/(about )?(\d+\.\d{2,})/g, (_x, about, n) => {
82
+ const v = Number(n);
83
+ if (v > 0 && v < 0.1)
84
+ return 'under 0.1';
85
+ return (about ?? '') + v.toFixed(1).replace(/\.0$/, '');
86
+ });
87
+ it('the exact reported refusal reads as one decimal / "under 0.1" with display names', () => {
88
+ const msg = (0, storageQuotaPreflight_1.storageQuotaPreflightMessage)({ kind: 'over-quota', fact: OWNER_FACT });
89
+ expect(msg).toBe("Your Pay-as-you-go plan allows 2 GB — you're already using about 4.0 GB " +
90
+ 'and this clone needs under 0.1 GB more. Upgrade to Pro (25 GB) to fit it.');
91
+ });
92
+ it("the arena's roundRefusalNumbers leaves the new text unchanged", () => {
93
+ const msg = (0, storageQuotaPreflight_1.storageQuotaPreflightMessage)({ kind: 'over-quota', fact: OWNER_FACT });
94
+ expect(arenaRound(msg)).toBe(msg);
95
+ });
96
+ it('a backend that already rounded (4, 0.023) gives the same sentence', () => {
97
+ const msg = (0, storageQuotaPreflight_1.storageQuotaPreflightMessage)({ kind: 'over-quota', fact: { ...OWNER_FACT, usedGb: 4, additionalGb: 0.023 } });
98
+ expect(msg).toContain('about 4.0 GB');
99
+ expect(msg).toContain('under 0.1 GB more');
100
+ });
101
+ it('an older backend without names falls back to the raw tier id', () => {
102
+ const msg = (0, storageQuotaPreflight_1.storageQuotaPreflightMessage)({
103
+ kind: 'over-quota',
104
+ fact: { ...OWNER_FACT, tierName: undefined, suggestedTierName: undefined },
105
+ });
106
+ expect(msg).toContain('Your payg plan');
107
+ expect(msg).toContain('Upgrade to pro (25 GB)');
108
+ });
109
+ it('aboutGb: one decimal, "under 0.1" for a positive sliver', () => {
110
+ expect((0, storageQuotaPreflight_1.aboutGb)(5.756542205810547)).toBe('about 5.8 GB');
111
+ expect((0, storageQuotaPreflight_1.aboutGb)(0.1)).toBe('about 0.1 GB');
112
+ expect((0, storageQuotaPreflight_1.aboutGb)(0.0999)).toBe('under 0.1 GB');
113
+ expect((0, storageQuotaPreflight_1.aboutGb)(25)).toBe('about 25.0 GB');
114
+ });
115
+ });
67
116
  describe('CLONE_QUOTA_PREFLIGHT_REFUSED — distinct from the free-space code', () => {
68
117
  it('is a real, distinct string from CLONE_SPACE_PREFLIGHT_REFUSED', () => {
69
118
  expect(storageQuotaPreflight_1.CLONE_QUOTA_PREFLIGHT_REFUSED).toBeTruthy();
@@ -8,7 +8,9 @@ export type PreviewStatus = 'stopped'
8
8
  | 'start_failed'
9
9
  /** Nothing could be attempted (see `reason`). */
10
10
  | 'failed';
11
- export type PreviewFailReason = 'busy_other_workspace' | 'no_repo_url' | 'git_failed' | 'no_package_json' | 'no_start_script' | 'port_in_use'
11
+ export type PreviewFailReason = 'busy_other_workspace' | 'no_repo_url' | 'git_failed' | 'no_package_json' | 'no_start_script'
12
+ /** tp-21 — a Python app with no `[tool.hiveop.preview] start` and no Procfile `web:` line. */
13
+ | 'no_start_command' | 'port_in_use'
12
14
  /** The app listened beyond 127.0.0.1 and could not be forced to loopback. */
13
15
  | 'unsupported_bind';
14
16
  export interface PreviewResult {
@@ -56,6 +58,8 @@ export interface PreviewRunnerOpts {
56
58
  killGraceMs?: number;
57
59
  /** This machine's non-loopback addresses (the bind check). Default: os.networkInterfaces(). */
58
60
  externalAddresses?: () => string[];
61
+ /** tp-21 — uv, its cache and fetched Pythons. Default `<previewsBaseDir>/.tools` (persistent). */
62
+ toolsDir?: string;
59
63
  }
60
64
  /** The only names a preview process takes from the bridge's own env. */
61
65
  /** The only address a preview app may listen on. */
@@ -94,6 +98,53 @@ export declare function listensBeyondLoopback(port: number, addresses: string[],
94
98
  export declare function parseSymrefHead(lsRemoteOutput: string): string | null;
95
99
  /** Which install command a checkout wants, by lockfile. */
96
100
  export declare function installCommand(files: (name: string) => boolean): [string, string[]];
101
+ export type PreviewCommand = [string, string[]];
102
+ /** Everything a deploy runs, in order, decided from the checkout alone. */
103
+ export interface PreviewPlan {
104
+ kind: 'node' | 'python';
105
+ /** Install, then build — each must exit 0. */
106
+ steps: PreviewCommand[];
107
+ start: PreviewCommand;
108
+ /** Added on top of the preview env for every step and the server (forced, after the repo .env). */
109
+ env: Record<string, string>;
110
+ }
111
+ export type PreviewPlanResult = {
112
+ ok: true;
113
+ plan: PreviewPlan;
114
+ } | {
115
+ ok: false;
116
+ reason: 'no_package_json' | 'no_start_script' | 'no_start_command';
117
+ message: string;
118
+ };
119
+ export interface PreviewCheckoutFiles {
120
+ has(name: string): boolean;
121
+ /** File text, or null when absent/unreadable. */
122
+ read(name: string): string | null;
123
+ }
124
+ export interface PreviewPlanContext {
125
+ /** Absolute checkout dir (the venv lives at `<slotDir>/.venv`). */
126
+ slotDir: string;
127
+ /** Persistent tools dir (uv binary, uv cache, fetched Pythons). */
128
+ toolsDir: string;
129
+ /** An existing uv executable, or null to install one into `<toolsDir>/bin`. */
130
+ uv: string | null;
131
+ /** The PATH the preview env starts from. */
132
+ basePath?: string;
133
+ }
134
+ /** The uv release the runner installs when the box has none (pinned: one known installer). */
135
+ export declare const PREVIEW_UV_VERSION = "0.13.0";
136
+ /** `[tool.hiveop.preview]` string keys from pyproject.toml text. Only plain `key = "…"` / `key = '…'` lines. */
137
+ export declare function readHiveopPreviewTable(toml: string): Record<string, string>;
138
+ /** The Procfile `web:` command, or null. */
139
+ export declare function procfileWeb(procfile: string): string | null;
140
+ /**
141
+ * Decide what a deploy runs. package.json → the Node path exactly as before
142
+ * (it wins even when Python files sit beside it). Otherwise a Python project
143
+ * → uv. Otherwise `no_package_json`. Pure: the checkout is read via `files`.
144
+ */
145
+ export declare function planPreview(files: PreviewCheckoutFiles, mode: PreviewMode, ctx: PreviewPlanContext): PreviewPlanResult;
146
+ /** An installed uv: PATH first, then ~/.local/bin (where ThisOrThat's script puts it), then the tools dir. */
147
+ export declare function findUv(pathEnv: string | undefined, home: string | undefined, toolsDir: string, isExec?: (p: string) => boolean): string | null;
97
148
  export declare class PreviewRunner {
98
149
  private readonly opts;
99
150
  private readonly states;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.PreviewRunner = exports.LOG_MASK_MIN_VALUE_LENGTH = exports.PREVIEW_PASSTHROUGH_ENV = exports.PREVIEW_BIND_HOST = void 0;
36
+ exports.PreviewRunner = exports.PREVIEW_UV_VERSION = exports.LOG_MASK_MIN_VALUE_LENGTH = exports.PREVIEW_PASSTHROUGH_ENV = exports.PREVIEW_BIND_HOST = void 0;
37
37
  exports.maskEnvValues = maskEnvValues;
38
38
  exports.buildPreviewEnv = buildPreviewEnv;
39
39
  exports.loopbackArgs = loopbackArgs;
@@ -41,6 +41,10 @@ exports.machineExternalAddresses = machineExternalAddresses;
41
41
  exports.listensBeyondLoopback = listensBeyondLoopback;
42
42
  exports.parseSymrefHead = parseSymrefHead;
43
43
  exports.installCommand = installCommand;
44
+ exports.readHiveopPreviewTable = readHiveopPreviewTable;
45
+ exports.procfileWeb = procfileWeb;
46
+ exports.planPreview = planPreview;
47
+ exports.findUv = findUv;
44
48
  exports.probeOnce = probeOnce;
45
49
  /**
46
50
  * tp-05 — Team preview checkout on the owner's HOSTED machine
@@ -102,6 +106,18 @@ exports.probeOnce = probeOnce;
102
106
  * machine has. If any of them connects, the new version is killed and the
103
107
  * request is refused `unsupported_bind` — never served exposed.
104
108
  *
109
+ * ── Python apps (tp-21) ─────────────────────────────────────────────────────
110
+ * A repo WITH package.json always takes the Node path above, unchanged. With
111
+ * no package.json, a `pyproject.toml` / `uv.lock` / `requirements.txt` makes
112
+ * it a Python preview, installed with uv (`uv sync`, or `uv venv` + `uv pip
113
+ * install -r`, so Debian's missing python3-venv never matters). The start
114
+ * command is never guessed: `[tool.hiveop.preview] start = "…"` in
115
+ * pyproject.toml, else a Procfile `web:` line, else `no_start_command`. uv
116
+ * itself, its cache and any Python it fetches live under
117
+ * `<previewsBaseDir>/.tools` (the /data volume), the venv in the slot, so a
118
+ * machine restart redownloads nothing. The system python3 is used unless the
119
+ * project pins a version it doesn't satisfy (then uv fetches that one).
120
+ *
105
121
  * Local lane: not here at all — see previewFrames.ts (ADR-0014 scopes it out).
106
122
  */
107
123
  const child_process_1 = require("child_process");
@@ -220,6 +236,125 @@ function runnerFor(files) {
220
236
  return 'yarn';
221
237
  return 'npm';
222
238
  }
239
+ /** The uv release the runner installs when the box has none (pinned: one known installer). */
240
+ exports.PREVIEW_UV_VERSION = '0.13.0';
241
+ /** `[tool.hiveop.preview]` string keys from pyproject.toml text. Only plain `key = "…"` / `key = '…'` lines. */
242
+ function readHiveopPreviewTable(toml) {
243
+ const out = {};
244
+ let inTable = false;
245
+ for (const raw of toml.split(/\r?\n/)) {
246
+ const line = raw.trim();
247
+ if (line.startsWith('[')) {
248
+ inTable = /^\[\s*tool\s*\.\s*hiveop\s*\.\s*preview\s*\]\s*(#.*)?$/.test(line);
249
+ continue;
250
+ }
251
+ if (!inTable)
252
+ continue;
253
+ const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(#.*)?$/);
254
+ if (!m)
255
+ continue;
256
+ out[m[1]] = m[2] !== undefined ? m[2].replace(/\\(["\\])/g, '$1') : m[3];
257
+ }
258
+ return out;
259
+ }
260
+ /** The Procfile `web:` command, or null. */
261
+ function procfileWeb(procfile) {
262
+ for (const line of procfile.split(/\r?\n/)) {
263
+ const m = line.match(/^web\s*:\s*(.+?)\s*$/);
264
+ if (m && m[1])
265
+ return m[1];
266
+ }
267
+ return null;
268
+ }
269
+ /**
270
+ * Decide what a deploy runs. package.json → the Node path exactly as before
271
+ * (it wins even when Python files sit beside it). Otherwise a Python project
272
+ * → uv. Otherwise `no_package_json`. Pure: the checkout is read via `files`.
273
+ */
274
+ function planPreview(files, mode, ctx) {
275
+ if (files.has('package.json')) {
276
+ const scripts = parseScripts(files.read('package.json'));
277
+ const runScript = mode === 'dev' ? 'dev' : 'start';
278
+ if (!scripts[runScript]) {
279
+ return { ok: false, reason: 'no_start_script', message: `No "${runScript}" script in package.json.` };
280
+ }
281
+ const has = (n) => files.has(n);
282
+ const steps = [installCommand(has)];
283
+ if (mode === 'built' && scripts.build)
284
+ steps.push([runnerFor(has), ['run', 'build']]);
285
+ return { ok: true, plan: { kind: 'node', steps, start: [runnerFor(has), runArgs(runScript, scripts[runScript])], env: {} } };
286
+ }
287
+ const pyproject = files.read('pyproject.toml');
288
+ const hasUvLock = files.has('uv.lock');
289
+ const hasRequirements = files.has('requirements.txt');
290
+ if (pyproject === null && !hasUvLock && !hasRequirements) {
291
+ return { ok: false, reason: 'no_package_json', message: 'No package.json or Python project on the default branch.' };
292
+ }
293
+ const table = pyproject ? readHiveopPreviewTable(pyproject) : {};
294
+ const procfile = files.read('Procfile');
295
+ const startCmd = table.start || (procfile ? procfileWeb(procfile) : null);
296
+ if (!startCmd) {
297
+ return {
298
+ ok: false,
299
+ reason: 'no_start_command',
300
+ message: 'No start command: add [tool.hiveop.preview] start to pyproject.toml, or a Procfile web: line.',
301
+ };
302
+ }
303
+ const venv = path.join(ctx.slotDir, '.venv');
304
+ const binDir = path.join(ctx.toolsDir, 'bin');
305
+ const uv = ctx.uv ?? path.join(binDir, 'uv');
306
+ const steps = [];
307
+ if (!ctx.uv) {
308
+ steps.push(['sh', ['-c',
309
+ `curl -LsSf https://astral.sh/uv/${exports.PREVIEW_UV_VERSION}/install.sh | env UV_INSTALL_DIR="${binDir}" UV_NO_MODIFY_PATH=1 sh`]]);
310
+ }
311
+ const isUvProject = hasUvLock || (pyproject !== null && /^\s*\[project\]\s*(#.*)?$/m.test(pyproject)) || !hasRequirements;
312
+ if (isUvProject) {
313
+ steps.push([uv, ['sync', ...(hasUvLock ? ['--frozen'] : []), '--no-dev']]);
314
+ }
315
+ else {
316
+ steps.push([uv, ['venv', '--allow-existing', venv]]);
317
+ steps.push([uv, ['pip', 'install', '-r', 'requirements.txt']]);
318
+ }
319
+ if (mode === 'built' && table.build)
320
+ steps.push(['sh', ['-c', table.build]]);
321
+ const basePath = ctx.basePath ?? '';
322
+ return {
323
+ ok: true,
324
+ plan: {
325
+ kind: 'python',
326
+ steps,
327
+ start: ['sh', ['-c', startCmd]],
328
+ env: {
329
+ PATH: [path.join(venv, 'bin'), binDir, basePath].filter(Boolean).join(path.delimiter),
330
+ VIRTUAL_ENV: venv,
331
+ UV_PROJECT_ENVIRONMENT: venv,
332
+ UV_CACHE_DIR: path.join(ctx.toolsDir, 'uv-cache'),
333
+ UV_PYTHON_INSTALL_DIR: path.join(ctx.toolsDir, 'python'),
334
+ UV_PYTHON_PREFERENCE: 'system',
335
+ PYTHONUNBUFFERED: '1',
336
+ },
337
+ },
338
+ };
339
+ }
340
+ /** An installed uv: PATH first, then ~/.local/bin (where ThisOrThat's script puts it), then the tools dir. */
341
+ function findUv(pathEnv, home, toolsDir, isExec = isExecutable) {
342
+ const candidates = [
343
+ ...(pathEnv ?? '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, 'uv')),
344
+ ...(home ? [path.join(home, '.local', 'bin', 'uv')] : []),
345
+ path.join(toolsDir, 'bin', 'uv'),
346
+ ];
347
+ return candidates.find(isExec) ?? null;
348
+ }
349
+ function isExecutable(p) {
350
+ try {
351
+ fs.accessSync(p, fs.constants.X_OK);
352
+ return fs.statSync(p).isFile();
353
+ }
354
+ catch {
355
+ return false;
356
+ }
357
+ }
223
358
  class PreviewRunner {
224
359
  opts;
225
360
  states = new Map();
@@ -239,6 +374,7 @@ class PreviewRunner {
239
374
  stepTimeoutMs: opts.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
240
375
  killGraceMs: opts.killGraceMs ?? 5_000,
241
376
  externalAddresses: opts.externalAddresses ?? machineExternalAddresses,
377
+ toolsDir: opts.toolsDir ?? path.join(opts.previewsBaseDir, '.tools'),
242
378
  };
243
379
  }
244
380
  /** Bring the preview up. Already serving this workspace in this mode → answered as-is. */
@@ -437,29 +573,38 @@ class PreviewRunner {
437
573
  this.states.set(workspaceId, { ...prev, status: 'running' });
438
574
  return this.snapshot(workspaceId, { sha });
439
575
  }
440
- const has = (name) => fs.existsSync(path.join(slotDir, name));
441
- if (!has('package.json')) {
442
- this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
443
- return this.withServing(workspaceId, { status: 'failed', reason: 'no_package_json', mode, sha, message: 'No package.json on the default branch.' });
444
- }
445
- const scripts = readScripts(path.join(slotDir, 'package.json'));
446
- const runScript = mode === 'dev' ? 'dev' : 'start';
447
- if (!scripts[runScript]) {
576
+ const files = {
577
+ has: (name) => fs.existsSync(path.join(slotDir, name)),
578
+ read: (name) => { try {
579
+ return fs.readFileSync(path.join(slotDir, name), 'utf8');
580
+ }
581
+ catch {
582
+ return null;
583
+ } },
584
+ };
585
+ const planned = planPreview(files, mode, {
586
+ slotDir,
587
+ toolsDir: this.opts.toolsDir,
588
+ uv: findUv(this.opts.baseEnv.PATH, this.opts.baseEnv.HOME, this.opts.toolsDir),
589
+ basePath: this.opts.baseEnv.PATH,
590
+ });
591
+ if (!planned.ok) {
448
592
  this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
449
- return this.withServing(workspaceId, {
450
- status: 'failed', reason: 'no_start_script', mode, sha,
451
- message: `No "${runScript}" script in package.json.`,
452
- });
593
+ return this.withServing(workspaceId, { status: 'failed', reason: planned.reason, mode, sha, message: planned.message });
453
594
  }
595
+ const { plan } = planned;
596
+ if (plan.kind === 'python')
597
+ fs.mkdirSync(this.opts.toolsDir, { recursive: true });
454
598
  const repoEnv = (0, repoEnv_1.loadRepoEnv)(this.opts.workingCopyDir(workspaceId));
455
599
  this.deployedEnvValues.set(workspaceId, Object.values(repoEnv));
456
- const env = buildPreviewEnv(this.opts.baseEnv, repoEnv, this.opts.port);
600
+ const env = { ...buildPreviewEnv(this.opts.baseEnv, repoEnv, this.opts.port), ...plan.env };
457
601
  // 2. Install, then (built) build — beside whatever is serving.
458
- const [installCmd, installArgs] = installCommand(has);
459
- const installed = await this.runStep(installCmd, installArgs, slotDir, env, logFile);
460
- let built = installed.ok;
461
- if (built && mode === 'built' && scripts.build) {
462
- built = (await this.runStep(runnerFor(has), ['run', 'build'], slotDir, env, logFile)).ok;
602
+ let built = true;
603
+ for (const [cmd, args] of plan.steps) {
604
+ if (!(await this.runStep(cmd, args, slotDir, env, logFile)).ok) {
605
+ built = false;
606
+ break;
607
+ }
463
608
  }
464
609
  if (!built) {
465
610
  this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'build_failed' });
@@ -476,7 +621,7 @@ class PreviewRunner {
476
621
  this.states.set(workspaceId, restored ?? { status: 'failed', mode, builtSha });
477
622
  return this.withServing(workspaceId, { status: 'failed', reason: 'port_in_use', mode, sha, builtSha, message: 'The preview port is in use.' });
478
623
  }
479
- const child = this.spawnServer(runnerFor(has), runArgs(runScript, scripts[runScript]), slotDir, env, logFile, workspaceId, targetSlot, sha);
624
+ const child = this.spawnServer(plan.start[0], plan.start[1], slotDir, env, logFile, workspaceId, targetSlot, sha);
480
625
  const ready = await this.waitReady(child, mode);
481
626
  if (ready && (await listensBeyondLoopback(this.opts.port, this.opts.externalAddresses()))) {
482
627
  // SECURITY: serving this would hand the owner's app (and keys) to every
@@ -492,7 +637,7 @@ class PreviewRunner {
492
637
  });
493
638
  }
494
639
  if (ready) {
495
- this.states.set(workspaceId, { status: 'running', mode, activeSlot: targetSlot, runningSha: sha, builtSha, child });
640
+ this.states.set(workspaceId, { status: 'running', mode, activeSlot: targetSlot, runningSha: sha, builtSha, child, start: plan.start, startEnv: env });
496
641
  return this.snapshot(workspaceId, { sha });
497
642
  }
498
643
  await this.killChild(child);
@@ -503,15 +648,15 @@ class PreviewRunner {
503
648
  }
504
649
  /** Bring the previous slot back after a failed swap, if there was one. */
505
650
  async restoreSlot(workspaceId, prev, env, logFile) {
506
- if (!prev.activeSlot || !prev.runningSha)
651
+ if (!prev.activeSlot || !prev.runningSha || !prev.start)
507
652
  return null;
508
653
  const dir = path.join(this.wsDir(workspaceId), prev.activeSlot);
509
- const has = (name) => fs.existsSync(path.join(dir, name));
510
- const script = prev.mode === 'dev' ? 'dev' : 'start';
654
+ // The serving version's own start command and env (its slot's venv, not the new one's).
655
+ const restoreEnv = prev.startEnv ?? env;
511
656
  fs.appendFileSync(logFile, `--- restoring ${prev.runningSha.slice(0, 7)} ---\n`);
512
- const child = this.spawnServer(runnerFor(has), runArgs(script, readScripts(path.join(dir, 'package.json'))[script] ?? ''), dir, env, logFile, workspaceId, prev.activeSlot, prev.runningSha);
657
+ const child = this.spawnServer(prev.start[0], prev.start[1], dir, restoreEnv, logFile, workspaceId, prev.activeSlot, prev.runningSha);
513
658
  if (await this.waitReady(child, prev.mode) && !(await listensBeyondLoopback(this.opts.port, this.opts.externalAddresses()))) {
514
- return { status: 'running', mode: prev.mode, activeSlot: prev.activeSlot, runningSha: prev.runningSha, builtSha: prev.builtSha, child };
659
+ return { status: 'running', mode: prev.mode, activeSlot: prev.activeSlot, runningSha: prev.runningSha, builtSha: prev.builtSha, child, start: prev.start, startEnv: restoreEnv };
515
660
  }
516
661
  await this.killChild(child);
517
662
  this.clearPidfile(workspaceId);
@@ -550,7 +695,8 @@ class PreviewRunner {
550
695
  await git(['-C', slotDir, 'reset', '--hard', `refs/remotes/origin/${branch}`]);
551
696
  // Untracked leftovers go; ignored ones (node_modules, build output) stay
552
697
  // so an unchanged dependency set doesn't reinstall from scratch.
553
- await git(['-C', slotDir, 'clean', '-ffd']);
698
+ // `.venv` is kept even when the repo doesn't ignore it (tp-21).
699
+ await git(['-C', slotDir, 'clean', '-ffd', '-e', '.venv']);
554
700
  const sha = (await git(['-C', slotDir, 'rev-parse', 'HEAD'])).trim();
555
701
  fs.appendFileSync(logFile, `default branch ${branch} @ ${sha.slice(0, 12)}\n`);
556
702
  return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
@@ -671,9 +817,9 @@ function runArgs(script, scriptCommand) {
671
817
  const extra = loopbackArgs(scriptCommand);
672
818
  return extra.length ? ['run', script, '--', ...extra] : ['run', script];
673
819
  }
674
- function readScripts(pkgPath) {
820
+ function parseScripts(pkgText) {
675
821
  try {
676
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
822
+ const pkg = JSON.parse(pkgText ?? '');
677
823
  return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
678
824
  }
679
825
  catch {
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fetchStorageQuotaFact = fetchStorageQuotaFact;
4
+ function optString(v) {
5
+ return typeof v === 'string' && v.length > 0 ? v : null;
6
+ }
4
7
  function isFiniteNumber(v) {
5
8
  return typeof v === 'number' && Number.isFinite(v);
6
9
  }
@@ -25,6 +28,8 @@ async function fetchStorageQuotaFact(opts) {
25
28
  additionalGb: opts.additionalGb,
26
29
  suggestedTier: null,
27
30
  suggestedCapGb: null,
31
+ tierName: optString(b.tierName),
32
+ suggestedTierName: null,
28
33
  };
29
34
  }
30
35
  if (res.status === 409) {
@@ -42,6 +47,8 @@ async function fetchStorageQuotaFact(opts) {
42
47
  additionalGb: err.additionalGb,
43
48
  suggestedTier: typeof err.suggestedTier === 'string' ? err.suggestedTier : null,
44
49
  suggestedCapGb: isFiniteNumber(err.suggestedCapGb) ? err.suggestedCapGb : null,
50
+ tierName: optString(err.tierName),
51
+ suggestedTierName: optString(err.suggestedTierName),
45
52
  };
46
53
  }
47
54
  }
@@ -64,7 +64,14 @@ export interface StorageQuotaFact {
64
64
  * when `allowed` is false. */
65
65
  suggestedTier: string | null;
66
66
  suggestedCapGb: number | null;
67
+ /** The backend's user-facing plan names ("Pay-as-you-go", "Pro", …).
68
+ * Absent from an older backend — the raw id is the fallback. */
69
+ tierName?: string | null;
70
+ suggestedTierName?: string | null;
67
71
  }
72
+ /** "about 4.0 GB", or "under 0.1 GB" for a positive sliver — never a raw
73
+ * float, never "about 0.0 GB" for something real. */
74
+ export declare function aboutGb(gb: number): string;
68
75
  export type StorageQuotaPreflightVerdict = {
69
76
  kind: 'within-quota';
70
77
  } | {
@@ -48,11 +48,19 @@
48
48
  */
49
49
  Object.defineProperty(exports, "__esModule", { value: true });
50
50
  exports.CLONE_QUOTA_PREFLIGHT_REFUSED = void 0;
51
+ exports.aboutGb = aboutGb;
51
52
  exports.checkStorageQuotaPreflight = checkStorageQuotaPreflight;
52
53
  exports.storageQuotaPreflightMessage = storageQuotaPreflightMessage;
53
54
  /** Frame-level discriminator so a pane can render this refusal distinctly
54
55
  * from CLONE_SPACE_PREFLIGHT_REFUSED — never the same string, on purpose. */
55
56
  exports.CLONE_QUOTA_PREFLIGHT_REFUSED = 'CLONE_QUOTA_PREFLIGHT_REFUSED';
57
+ /** "about 4.0 GB", or "under 0.1 GB" for a positive sliver — never a raw
58
+ * float, never "about 0.0 GB" for something real. */
59
+ function aboutGb(gb) {
60
+ if (gb > 0 && gb < 0.1)
61
+ return 'under 0.1 GB';
62
+ return `about ${gb.toFixed(1)} GB`;
63
+ }
56
64
  /**
57
65
  * `fact` of `null` means UNKNOWN (see module doc) — returns `null`, meaning
58
66
  * "no verdict, proceed exactly as before this preflight existed". A
@@ -76,10 +84,10 @@ function storageQuotaPreflightMessage(verdict) {
76
84
  if (!verdict || verdict.kind === 'within-quota')
77
85
  return null;
78
86
  const { fact } = verdict;
79
- const usage = `Your ${fact.tier} plan allows ${fact.capGb} GB — you're already using about ${fact.usedGb} GB ` +
80
- `and this clone needs about ${fact.additionalGb} GB more.`;
87
+ const usage = `Your ${fact.tierName || fact.tier} plan allows ${fact.capGb} GB — you're already using ${aboutGb(fact.usedGb)} ` +
88
+ `and this clone needs ${aboutGb(fact.additionalGb)} more.`;
81
89
  if (fact.suggestedTier && fact.suggestedCapGb !== null) {
82
- return `${usage} Upgrade to ${fact.suggestedTier} (${fact.suggestedCapGb} GB) to fit it.`;
90
+ return `${usage} Upgrade to ${fact.suggestedTierName || fact.suggestedTier} (${fact.suggestedCapGb} GB) to fit it.`;
83
91
  }
84
92
  return `${usage} No plan on the current ladder holds this much — contact support about additional storage.`;
85
93
  }
package/dist/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.8.10",
3
+ "version": "0.8.11",
4
4
  "description": "CLI tool for the Wenrwa Agent Marketplace — monitor bounties, agents, workspaces, and treasury from your terminal",
5
5
  "author": "Wenrwa <dev@wenrwa.com>",
6
6
  "license": "MIT",