@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.
- package/dist/__tests__/bridge-preview-python.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-python.test.js +287 -0
- package/dist/__tests__/bridge-storage-quota-fetch.test.js +32 -0
- package/dist/__tests__/bridge-storage-quota-preflight.test.js +49 -0
- package/dist/bridge/previewCheckout.d.ts +52 -1
- package/dist/bridge/previewCheckout.js +175 -29
- package/dist/bridge/storageQuotaFetch.js +7 -0
- package/dist/bridge/storageQuotaPreflight.d.ts +7 -0
- package/dist/bridge/storageQuotaPreflight.js +11 -3
- package/dist/index.js +0 -0
- package/package.json +1 -1
|
@@ -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'
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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(
|
|
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
|
-
|
|
510
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
820
|
+
function parseScripts(pkgText) {
|
|
675
821
|
try {
|
|
676
|
-
const pkg = JSON.parse(
|
|
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
|
|
80
|
-
`and this clone needs
|
|
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.
|
|
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",
|