@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.
- package/dist/__tests__/bridge-preview-checkout.test.js +16 -0
- 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 +66 -1
- package/dist/bridge/previewCheckout.js +205 -30
- 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
|
@@ -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'
|
|
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
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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(
|
|
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
|
-
|
|
481
|
-
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;
|
|
482
656
|
fs.appendFileSync(logFile, `--- restoring ${prev.runningSha.slice(0, 7)} ---\n`);
|
|
483
|
-
const child = this.spawnServer(
|
|
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
|
-
|
|
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
|
|
820
|
+
function parseScripts(pkgText) {
|
|
646
821
|
try {
|
|
647
|
-
const pkg = JSON.parse(
|
|
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
|
|
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",
|