@wenrwa/marketplace-cli 0.8.9 → 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.
@@ -215,6 +215,22 @@ describe('PreviewRunner (tp-05) — real git, real processes', () => {
215
215
  expect(await runner.update({ workspaceId: 'ws-1', repoUrl: fx.remote })).toMatchObject({ status: 'running', runningSha: fixed });
216
216
  expect(JSON.parse(await get(port)).message).toBe('v3 fixed');
217
217
  });
218
+ it('tp-17: the log reply never carries a .env value, even one printed bare by the app', async () => {
219
+ // A value no token pattern recognises, logged on its own line by merged app code.
220
+ fs.appendFileSync(path.join(fx.workingCopy, '.env'), 'PLAIN_SECRET=plainvalue9f3a2b7c41\nSHORT=on\n');
221
+ pushChange(fx, {
222
+ 'server.js': `console.log(process.env.PLAIN_SECRET); console.log('using ' + process.env.OWNER_SECRET + ' and ' + process.env.SHORT);\n${SERVER_JS}`,
223
+ }, 'logs a key');
224
+ const r = await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
225
+ expect(r.status).toBe('running');
226
+ await new Promise((res) => setTimeout(res, 300));
227
+ const { log } = runner.logs({ workspaceId: 'ws-1' });
228
+ expect(log).toContain('using ');
229
+ expect(log).not.toContain('plainvalue9f3a2b7c41');
230
+ expect(log).not.toContain('sk_live_owner_value');
231
+ // Short values (flags, ports) stay readable: masking them would blank the log.
232
+ expect(log).toContain(' and on');
233
+ });
218
234
  it('pins to the remote default branch — another branch pushed to the remote never runs', async () => {
219
235
  git(fx.author, 'checkout', '-b', 'feature');
220
236
  fs.writeFileSync(path.join(fx.author, 'message.txt'), 'feature branch\n');
@@ -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,11 +58,25 @@ 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. */
62
66
  export declare const PREVIEW_BIND_HOST = "127.0.0.1";
63
67
  export declare const PREVIEW_PASSTHROUGH_ENV: readonly ["PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "TMPDIR", "SHELL"];
68
+ /**
69
+ * tp-17 — `.env` values this long or longer are masked in the log reply by
70
+ * VALUE, whatever their shape. Shorter ones (ports, flags, `production`) stay:
71
+ * masking them would blank half the log and they are not secrets.
72
+ */
73
+ export declare const LOG_MASK_MIN_VALUE_LENGTH = 8;
74
+ /**
75
+ * tp-17 — mask every given value in `text`, longest first (so a value that
76
+ * contains another is masked whole). Pattern redaction (`redact`) misses a key
77
+ * the app prints bare with no recognisable shape; this never does.
78
+ */
79
+ export declare function maskEnvValues(text: string, values: Iterable<string>): string;
64
80
  /**
65
81
  * The preview env: allowlisted process names < the owner's repo `.env`
66
82
  * (reserved names already removed by loadRepoEnv) < the fixed PORT.
@@ -82,9 +98,58 @@ export declare function listensBeyondLoopback(port: number, addresses: string[],
82
98
  export declare function parseSymrefHead(lsRemoteOutput: string): string | null;
83
99
  /** Which install command a checkout wants, by lockfile. */
84
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;
85
148
  export declare class PreviewRunner {
86
149
  private readonly opts;
87
150
  private readonly states;
151
+ /** tp-17 — the `.env` values each workspace's last deploy ran with (log masking). */
152
+ private readonly deployedEnvValues;
88
153
  /** One port, so one operation at a time across the whole box. */
89
154
  private chain;
90
155
  private orphanSwept;
@@ -33,13 +33,18 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.PreviewRunner = 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
+ exports.maskEnvValues = maskEnvValues;
37
38
  exports.buildPreviewEnv = buildPreviewEnv;
38
39
  exports.loopbackArgs = loopbackArgs;
39
40
  exports.machineExternalAddresses = machineExternalAddresses;
40
41
  exports.listensBeyondLoopback = listensBeyondLoopback;
41
42
  exports.parseSymrefHead = parseSymrefHead;
42
43
  exports.installCommand = installCommand;
44
+ exports.readHiveopPreviewTable = readHiveopPreviewTable;
45
+ exports.procfileWeb = procfileWeb;
46
+ exports.planPreview = planPreview;
47
+ exports.findUv = findUv;
43
48
  exports.probeOnce = probeOnce;
44
49
  /**
45
50
  * tp-05 — Team preview checkout on the owner's HOSTED machine
@@ -101,6 +106,18 @@ exports.probeOnce = probeOnce;
101
106
  * machine has. If any of them connects, the new version is killed and the
102
107
  * request is refused `unsupported_bind` — never served exposed.
103
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
+ *
104
121
  * Local lane: not here at all — see previewFrames.ts (ADR-0014 scopes it out).
105
122
  */
106
123
  const child_process_1 = require("child_process");
@@ -116,6 +133,24 @@ const redact_1 = require("../secrets/redact");
116
133
  /** The only address a preview app may listen on. */
117
134
  exports.PREVIEW_BIND_HOST = '127.0.0.1';
118
135
  exports.PREVIEW_PASSTHROUGH_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'SHELL'];
136
+ /**
137
+ * tp-17 — `.env` values this long or longer are masked in the log reply by
138
+ * VALUE, whatever their shape. Shorter ones (ports, flags, `production`) stay:
139
+ * masking them would blank half the log and they are not secrets.
140
+ */
141
+ exports.LOG_MASK_MIN_VALUE_LENGTH = 8;
142
+ /**
143
+ * tp-17 — mask every given value in `text`, longest first (so a value that
144
+ * contains another is masked whole). Pattern redaction (`redact`) misses a key
145
+ * the app prints bare with no recognisable shape; this never does.
146
+ */
147
+ function maskEnvValues(text, values) {
148
+ const list = [...new Set([...values].filter((v) => v.length >= exports.LOG_MASK_MIN_VALUE_LENGTH))].sort((a, b) => b.length - a.length);
149
+ let out = text;
150
+ for (const v of list)
151
+ out = out.split(v).join('*'.repeat(v.length));
152
+ return out;
153
+ }
119
154
  const DEFAULT_READY_TIMEOUT = { built: 120_000, dev: 300_000 };
120
155
  const DEFAULT_STEP_TIMEOUT_MS = 15 * 60_000;
121
156
  const LOG_TAIL_LINES = 200;
@@ -201,9 +236,130 @@ function runnerFor(files) {
201
236
  return 'yarn';
202
237
  return 'npm';
203
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
+ }
204
358
  class PreviewRunner {
205
359
  opts;
206
360
  states = new Map();
361
+ /** tp-17 — the `.env` values each workspace's last deploy ran with (log masking). */
362
+ deployedEnvValues = new Map();
207
363
  /** One port, so one operation at a time across the whole box. */
208
364
  chain = Promise.resolve();
209
365
  orphanSwept = false;
@@ -218,6 +374,7 @@ class PreviewRunner {
218
374
  stepTimeoutMs: opts.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
219
375
  killGraceMs: opts.killGraceMs ?? 5_000,
220
376
  externalAddresses: opts.externalAddresses ?? machineExternalAddresses,
377
+ toolsDir: opts.toolsDir ?? path.join(opts.previewsBaseDir, '.tools'),
221
378
  };
222
379
  }
223
380
  /** Bring the preview up. Already serving this workspace in this mode → answered as-is. */
@@ -280,7 +437,13 @@ class PreviewRunner {
280
437
  out = out.slice(-LOG_TAIL_MAX_CHARS);
281
438
  truncated = true;
282
439
  }
283
- return { log: (0, redact_1.redact)(out), truncated };
440
+ // Pattern masking, then the owner's own .env values by value — the ones
441
+ // the last deploy ran with and the ones on disk now (an edited .env).
442
+ const values = [
443
+ ...(this.deployedEnvValues.get(req.workspaceId) ?? []),
444
+ ...Object.values((0, repoEnv_1.loadRepoEnv)(this.opts.workingCopyDir(req.workspaceId))),
445
+ ];
446
+ return { log: maskEnvValues((0, redact_1.redact)(out), values), truncated };
284
447
  }
285
448
  status(workspaceId) {
286
449
  return this.snapshot(workspaceId, {});
@@ -410,27 +573,38 @@ class PreviewRunner {
410
573
  this.states.set(workspaceId, { ...prev, status: 'running' });
411
574
  return this.snapshot(workspaceId, { sha });
412
575
  }
413
- const has = (name) => fs.existsSync(path.join(slotDir, name));
414
- if (!has('package.json')) {
415
- this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
416
- return this.withServing(workspaceId, { status: 'failed', reason: 'no_package_json', mode, sha, message: 'No package.json on the default branch.' });
417
- }
418
- const scripts = readScripts(path.join(slotDir, 'package.json'));
419
- const runScript = mode === 'dev' ? 'dev' : 'start';
420
- 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) {
421
592
  this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
422
- return this.withServing(workspaceId, {
423
- status: 'failed', reason: 'no_start_script', mode, sha,
424
- message: `No "${runScript}" script in package.json.`,
425
- });
593
+ return this.withServing(workspaceId, { status: 'failed', reason: planned.reason, mode, sha, message: planned.message });
426
594
  }
427
- const env = buildPreviewEnv(this.opts.baseEnv, (0, repoEnv_1.loadRepoEnv)(this.opts.workingCopyDir(workspaceId)), this.opts.port);
595
+ const { plan } = planned;
596
+ if (plan.kind === 'python')
597
+ fs.mkdirSync(this.opts.toolsDir, { recursive: true });
598
+ const repoEnv = (0, repoEnv_1.loadRepoEnv)(this.opts.workingCopyDir(workspaceId));
599
+ this.deployedEnvValues.set(workspaceId, Object.values(repoEnv));
600
+ const env = { ...buildPreviewEnv(this.opts.baseEnv, repoEnv, this.opts.port), ...plan.env };
428
601
  // 2. Install, then (built) build — beside whatever is serving.
429
- const [installCmd, installArgs] = installCommand(has);
430
- const installed = await this.runStep(installCmd, installArgs, slotDir, env, logFile);
431
- let built = installed.ok;
432
- if (built && mode === 'built' && scripts.build) {
433
- 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
+ }
434
608
  }
435
609
  if (!built) {
436
610
  this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'build_failed' });
@@ -447,7 +621,7 @@ class PreviewRunner {
447
621
  this.states.set(workspaceId, restored ?? { status: 'failed', mode, builtSha });
448
622
  return this.withServing(workspaceId, { status: 'failed', reason: 'port_in_use', mode, sha, builtSha, message: 'The preview port is in use.' });
449
623
  }
450
- 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);
451
625
  const ready = await this.waitReady(child, mode);
452
626
  if (ready && (await listensBeyondLoopback(this.opts.port, this.opts.externalAddresses()))) {
453
627
  // SECURITY: serving this would hand the owner's app (and keys) to every
@@ -463,7 +637,7 @@ class PreviewRunner {
463
637
  });
464
638
  }
465
639
  if (ready) {
466
- 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 });
467
641
  return this.snapshot(workspaceId, { sha });
468
642
  }
469
643
  await this.killChild(child);
@@ -474,15 +648,15 @@ class PreviewRunner {
474
648
  }
475
649
  /** Bring the previous slot back after a failed swap, if there was one. */
476
650
  async restoreSlot(workspaceId, prev, env, logFile) {
477
- if (!prev.activeSlot || !prev.runningSha)
651
+ if (!prev.activeSlot || !prev.runningSha || !prev.start)
478
652
  return null;
479
653
  const dir = path.join(this.wsDir(workspaceId), prev.activeSlot);
480
- const has = (name) => fs.existsSync(path.join(dir, name));
481
- 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;
482
656
  fs.appendFileSync(logFile, `--- restoring ${prev.runningSha.slice(0, 7)} ---\n`);
483
- 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);
484
658
  if (await this.waitReady(child, prev.mode) && !(await listensBeyondLoopback(this.opts.port, this.opts.externalAddresses()))) {
485
- 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 };
486
660
  }
487
661
  await this.killChild(child);
488
662
  this.clearPidfile(workspaceId);
@@ -521,7 +695,8 @@ class PreviewRunner {
521
695
  await git(['-C', slotDir, 'reset', '--hard', `refs/remotes/origin/${branch}`]);
522
696
  // Untracked leftovers go; ignored ones (node_modules, build output) stay
523
697
  // so an unchanged dependency set doesn't reinstall from scratch.
524
- 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']);
525
700
  const sha = (await git(['-C', slotDir, 'rev-parse', 'HEAD'])).trim();
526
701
  fs.appendFileSync(logFile, `default branch ${branch} @ ${sha.slice(0, 12)}\n`);
527
702
  return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
@@ -642,9 +817,9 @@ function runArgs(script, scriptCommand) {
642
817
  const extra = loopbackArgs(scriptCommand);
643
818
  return extra.length ? ['run', script, '--', ...extra] : ['run', script];
644
819
  }
645
- function readScripts(pkgPath) {
820
+ function parseScripts(pkgText) {
646
821
  try {
647
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
822
+ const pkg = JSON.parse(pkgText ?? '');
648
823
  return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
649
824
  }
650
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.9",
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",