@wenrwa/marketplace-cli 0.8.7 → 0.8.9
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-env-names.test.d.ts +1 -0
- package/dist/__tests__/bridge-env-names.test.js +243 -0
- package/dist/__tests__/bridge-preview-checkout.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-checkout.test.js +294 -0
- package/dist/__tests__/bridge-preview-forwarder.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-forwarder.test.js +465 -0
- package/dist/__tests__/bridge-preview-frames.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-frames.test.js +165 -0
- package/dist/__tests__/bridge-session-anchor-no-identity.test.d.ts +1 -0
- package/dist/__tests__/bridge-session-anchor-no-identity.test.js +74 -0
- package/dist/__tests__/bridge-session-anchor.test.js +10 -2
- package/dist/bridge/envNames.d.ts +26 -0
- package/dist/bridge/envNames.js +114 -0
- package/dist/bridge/previewCheckout.d.ts +136 -0
- package/dist/bridge/previewCheckout.js +677 -0
- package/dist/bridge/previewForwarder.d.ts +116 -0
- package/dist/bridge/previewForwarder.js +323 -0
- package/dist/bridge/previewFrames.d.ts +63 -0
- package/dist/bridge/previewFrames.js +59 -0
- package/dist/bridge/relayClient.d.ts +15 -0
- package/dist/bridge/relayClient.js +36 -0
- package/dist/bridge/server.d.ts +7 -0
- package/dist/bridge/server.js +40 -1
- package/dist/bridge/sessionAnchor.d.ts +9 -0
- package/dist/bridge/sessionAnchor.js +16 -1
- package/dist/commands/bridge.js +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,243 @@
|
|
|
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-02 (docs/prd/team-preview.md story 39) — the Keys list reads key NAMES
|
|
38
|
+
* from a workspace's `.env`, never the values. Both lanes:
|
|
39
|
+
* - hosted: relay `env-names` frame → `env-names-result` (requestId echoed)
|
|
40
|
+
* - local: GET /local-env-names/:workspaceId on the loopback bridge
|
|
41
|
+
* Every test runs a real `.env` fixture on disk and asserts the bytes that
|
|
42
|
+
* actually leave the bridge.
|
|
43
|
+
*/
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const ws_1 = require("ws");
|
|
48
|
+
const envNames_1 = require("../bridge/envNames");
|
|
49
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
50
|
+
const server_1 = require("../bridge/server");
|
|
51
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
52
|
+
class FakePty {
|
|
53
|
+
write() { }
|
|
54
|
+
resize() { }
|
|
55
|
+
kill() { }
|
|
56
|
+
onData() { }
|
|
57
|
+
onExit() { }
|
|
58
|
+
}
|
|
59
|
+
const factory = { spawn: () => new FakePty() };
|
|
60
|
+
const SECRET = 'sk_live_9f8e7d6c5b4a';
|
|
61
|
+
const PUBLIC_VALUE = 'pk_test_visible_x';
|
|
62
|
+
function fixtureRoot(envText) {
|
|
63
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tp02-env-'));
|
|
64
|
+
if (envText !== null)
|
|
65
|
+
fs.writeFileSync(path.join(root, '.env'), envText);
|
|
66
|
+
return root;
|
|
67
|
+
}
|
|
68
|
+
const FIXTURE = [
|
|
69
|
+
'# my keys',
|
|
70
|
+
`FOO=${SECRET}`,
|
|
71
|
+
`export NEXT_PUBLIC_BAR="${PUBLIC_VALUE}"`,
|
|
72
|
+
'not a line',
|
|
73
|
+
'',
|
|
74
|
+
].join('\n');
|
|
75
|
+
function assertNoValueBytes(raw) {
|
|
76
|
+
expect(raw).not.toContain(SECRET);
|
|
77
|
+
expect(raw).not.toContain(PUBLIC_VALUE);
|
|
78
|
+
expect(raw).not.toContain('sk_live');
|
|
79
|
+
}
|
|
80
|
+
describe('readEnvNames (tp-02)', () => {
|
|
81
|
+
it('a .env with FOO=secret and NEXT_PUBLIC_BAR=x yields ["FOO","NEXT_PUBLIC_BAR"] and no value', () => {
|
|
82
|
+
const root = fixtureRoot(FIXTURE);
|
|
83
|
+
const result = (0, envNames_1.readEnvNames)(root);
|
|
84
|
+
expect(result).toEqual({ ok: true, names: ['FOO', 'NEXT_PUBLIC_BAR'] });
|
|
85
|
+
assertNoValueBytes(JSON.stringify(result));
|
|
86
|
+
});
|
|
87
|
+
it('no .env yet → an empty list, not an error', () => {
|
|
88
|
+
expect((0, envNames_1.readEnvNames)(fixtureRoot(null))).toEqual({ ok: true, names: [] });
|
|
89
|
+
});
|
|
90
|
+
it('no folder → a typed refusal', () => {
|
|
91
|
+
expect((0, envNames_1.readEnvNames)(null)).toEqual({ ok: false, reason: 'no-folder' });
|
|
92
|
+
});
|
|
93
|
+
it('a symlinked .env is not followed', () => {
|
|
94
|
+
const elsewhere = fixtureRoot(FIXTURE);
|
|
95
|
+
const root = fixtureRoot(null);
|
|
96
|
+
fs.symlinkSync(path.join(elsewhere, '.env'), path.join(root, '.env'));
|
|
97
|
+
expect((0, envNames_1.readEnvNames)(root)).toEqual({ ok: false, reason: 'not-a-file' });
|
|
98
|
+
});
|
|
99
|
+
it('duplicate names are listed once, sorted', () => {
|
|
100
|
+
const root = fixtureRoot('ZED=1\nALPHA=2\nZED=3\n');
|
|
101
|
+
expect((0, envNames_1.readEnvNames)(root)).toEqual({ ok: true, names: ['ALPHA', 'ZED'] });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('handleEnvNamesFrame (tp-02)', () => {
|
|
105
|
+
it('echoes requestId + workspaceId and carries names only', () => {
|
|
106
|
+
const root = fixtureRoot(FIXTURE);
|
|
107
|
+
const reply = (0, envNames_1.handleEnvNamesFrame)({ requestId: 'en-1', workspaceId: 'ws-1' }, (id) => (id === 'ws-1' ? root : null));
|
|
108
|
+
expect(reply).toEqual({ t: 'env-names-result', requestId: 'en-1', workspaceId: 'ws-1', ok: true, names: ['FOO', 'NEXT_PUBLIC_BAR'] });
|
|
109
|
+
assertNoValueBytes(JSON.stringify(reply));
|
|
110
|
+
});
|
|
111
|
+
it('an unresolvable workspace → typed refusal with requestId (never a hang)', () => {
|
|
112
|
+
const reply = (0, envNames_1.handleEnvNamesFrame)({ requestId: 'en-2', workspaceId: '../etc' }, () => null);
|
|
113
|
+
expect(reply).toEqual({ t: 'env-names-result', requestId: 'en-2', workspaceId: '../etc', ok: false, reason: 'no-folder' });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// ── hosted lane: the relay frame ────────────────────────────────────────────
|
|
117
|
+
function frameQueue(ws) {
|
|
118
|
+
const frames = [];
|
|
119
|
+
const waiters = [];
|
|
120
|
+
ws.on('message', (raw) => {
|
|
121
|
+
const s = String(raw);
|
|
122
|
+
const w = waiters.shift();
|
|
123
|
+
if (w)
|
|
124
|
+
w(s);
|
|
125
|
+
else
|
|
126
|
+
frames.push(s);
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
nextRaw: () => frames.length > 0 ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function startFakeRelay() {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
135
|
+
const queue = [];
|
|
136
|
+
const waiters = [];
|
|
137
|
+
wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
|
|
138
|
+
w(ws);
|
|
139
|
+
else
|
|
140
|
+
queue.push(ws); });
|
|
141
|
+
wss.on('listening', () => {
|
|
142
|
+
const addr = wss.address();
|
|
143
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
144
|
+
resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function closeRelay(wss) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
for (const client of wss.clients)
|
|
151
|
+
client.terminate();
|
|
152
|
+
wss.close((err) => (err ? reject(err) : resolve()));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
describe('relay client — env-names frame (tp-02, hosted lane)', () => {
|
|
156
|
+
it('replies env-names-result with the names; no value byte is on the wire', async () => {
|
|
157
|
+
const root = fixtureRoot(FIXTURE);
|
|
158
|
+
const relay = await startFakeRelay();
|
|
159
|
+
const sessions = new sessionManager_1.SessionManager(factory);
|
|
160
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
161
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
162
|
+
relayToken: 'tok',
|
|
163
|
+
sessions,
|
|
164
|
+
resolveEnvNamesRoot: (workspaceId) => (workspaceId === 'ws-7' ? root : null),
|
|
165
|
+
});
|
|
166
|
+
const ws = await relay.nextConnection();
|
|
167
|
+
const q = frameQueue(ws);
|
|
168
|
+
await q.nextRaw(); // auth
|
|
169
|
+
ws.send(JSON.stringify({ t: 'env-names', requestId: 'req-1', workspaceId: 'ws-7' }));
|
|
170
|
+
const raw = await q.nextRaw();
|
|
171
|
+
expect(JSON.parse(raw)).toEqual({ t: 'env-names-result', requestId: 'req-1', workspaceId: 'ws-7', ok: true, names: ['FOO', 'NEXT_PUBLIC_BAR'] });
|
|
172
|
+
assertNoValueBytes(raw);
|
|
173
|
+
// A file read, never a PTY.
|
|
174
|
+
expect(sessions.list()).toEqual([]);
|
|
175
|
+
handle.close();
|
|
176
|
+
await closeRelay(relay.wss);
|
|
177
|
+
});
|
|
178
|
+
it('a bridge without the reader wired replies a typed refusal echoing requestId', async () => {
|
|
179
|
+
const relay = await startFakeRelay();
|
|
180
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
181
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
182
|
+
relayToken: 'tok',
|
|
183
|
+
sessions: new sessionManager_1.SessionManager(factory),
|
|
184
|
+
});
|
|
185
|
+
const ws = await relay.nextConnection();
|
|
186
|
+
const q = frameQueue(ws);
|
|
187
|
+
await q.nextRaw(); // auth
|
|
188
|
+
ws.send(JSON.stringify({ t: 'env-names', requestId: 'req-2', workspaceId: 'ws-7' }));
|
|
189
|
+
expect(JSON.parse(await q.nextRaw())).toEqual({
|
|
190
|
+
t: 'env-names-result', requestId: 'req-2', workspaceId: 'ws-7', ok: false, reason: 'bridge-cannot-read-env-names',
|
|
191
|
+
});
|
|
192
|
+
handle.close();
|
|
193
|
+
await closeRelay(relay.wss);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
// ── local lane: the loopback route ──────────────────────────────────────────
|
|
197
|
+
const ORIGIN = 'https://app.hiveop.io';
|
|
198
|
+
const TOKEN = 'session-token-123';
|
|
199
|
+
describe('bridge HTTP server — GET /local-env-names/:workspaceId (tp-02, local lane)', () => {
|
|
200
|
+
let handle;
|
|
201
|
+
afterEach(async () => { await handle.close(); });
|
|
202
|
+
const start = (root) => (0, server_1.startBridgeServer)({
|
|
203
|
+
sessionToken: TOKEN,
|
|
204
|
+
allowedOrigin: ORIGIN,
|
|
205
|
+
sessions: new sessionManager_1.SessionManager(factory),
|
|
206
|
+
resolveEnvNamesRoot: (workspaceId) => (workspaceId === 'ws-1' ? root : null),
|
|
207
|
+
});
|
|
208
|
+
const get = (workspaceId, opts = {}) => fetch(`http://127.0.0.1:${handle.port}/local-env-names/${workspaceId}${opts.token !== undefined ? `?token=${opts.token}` : ''}`, { headers: { origin: opts.origin ?? ORIGIN } });
|
|
209
|
+
it('returns the names for a registered workspace; no value byte in the body', async () => {
|
|
210
|
+
handle = await start(fixtureRoot(FIXTURE));
|
|
211
|
+
const res = await get('ws-1', { token: TOKEN });
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
expect(res.headers.get('access-control-allow-origin')).toBe(ORIGIN);
|
|
214
|
+
const raw = await res.text();
|
|
215
|
+
expect(JSON.parse(raw)).toEqual({ names: ['FOO', 'NEXT_PUBLIC_BAR'] });
|
|
216
|
+
assertNoValueBytes(raw);
|
|
217
|
+
});
|
|
218
|
+
it('401 without the session token, 403 from a foreign origin', async () => {
|
|
219
|
+
handle = await start(fixtureRoot(FIXTURE));
|
|
220
|
+
expect((await get('ws-1')).status).toBe(401);
|
|
221
|
+
expect((await get('ws-1', { token: 'nope' })).status).toBe(401);
|
|
222
|
+
expect((await get('ws-1', { token: TOKEN, origin: 'https://evil.example' })).status).toBe(403);
|
|
223
|
+
});
|
|
224
|
+
it('404 for a workspace not registered on this device', async () => {
|
|
225
|
+
handle = await start(fixtureRoot(FIXTURE));
|
|
226
|
+
expect((await get('ws-other', { token: TOKEN })).status).toBe(404);
|
|
227
|
+
});
|
|
228
|
+
it('answers the Private-Network preflight', async () => {
|
|
229
|
+
handle = await start(fixtureRoot(FIXTURE));
|
|
230
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/local-env-names/ws-1`, {
|
|
231
|
+
method: 'OPTIONS',
|
|
232
|
+
headers: { origin: ORIGIN },
|
|
233
|
+
});
|
|
234
|
+
expect(res.status).toBe(204);
|
|
235
|
+
expect(res.headers.get('access-control-allow-private-network')).toBe('true');
|
|
236
|
+
});
|
|
237
|
+
it('/discovery advertises the env-names capability when wired', async () => {
|
|
238
|
+
handle = await start(fixtureRoot(FIXTURE));
|
|
239
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: ORIGIN } });
|
|
240
|
+
const body = await res.json();
|
|
241
|
+
expect(body.capabilities).toContain('env-names');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
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-05 — the Team preview checkout on the owner's hosted machine, against a
|
|
38
|
+
* REAL git fixture and REAL child processes (docs/prd/team-preview.md
|
|
39
|
+
* module 5, ADR-0014).
|
|
40
|
+
*
|
|
41
|
+
* The fixture: a bare "remote", the owner's working copy cloned from it (with
|
|
42
|
+
* a gitignored `.env` and, in one test, an uncommitted edit), and a tiny
|
|
43
|
+
* package whose `build` copies message.txt into dist/ (or fails when
|
|
44
|
+
* BREAK_BUILD exists) and whose `start` serves dist/ plus two env values on
|
|
45
|
+
* PORT. Every server is stopped by exact PID in teardown (runner.shutdown).
|
|
46
|
+
*/
|
|
47
|
+
const child_process_1 = require("child_process");
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const http = __importStar(require("http"));
|
|
50
|
+
const net = __importStar(require("net"));
|
|
51
|
+
const os = __importStar(require("os"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const previewCheckout_1 = require("../bridge/previewCheckout");
|
|
54
|
+
jest.setTimeout(120_000);
|
|
55
|
+
function git(cwd, ...args) {
|
|
56
|
+
return (0, child_process_1.execFileSync)('git', args, {
|
|
57
|
+
cwd,
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function freePort() {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const srv = net.createServer();
|
|
65
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
66
|
+
const port = srv.address().port;
|
|
67
|
+
srv.close(() => resolve(port));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function get(port) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
http.get({ host: '127.0.0.1', port, path: '/' }, (res) => {
|
|
74
|
+
let body = '';
|
|
75
|
+
res.on('data', (c) => (body += c));
|
|
76
|
+
res.on('end', () => resolve(body));
|
|
77
|
+
}).on('error', reject);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const BUILD_JS = `
|
|
81
|
+
const fs = require('fs');
|
|
82
|
+
if (fs.existsSync('BREAK_BUILD')) { console.error('build broke on purpose'); process.exit(1); }
|
|
83
|
+
fs.mkdirSync('dist', { recursive: true });
|
|
84
|
+
fs.copyFileSync('message.txt', 'dist/message.txt');
|
|
85
|
+
`;
|
|
86
|
+
const SERVER_JS = `
|
|
87
|
+
const fs = require('fs');
|
|
88
|
+
require('http').createServer((req, res) => {
|
|
89
|
+
res.end(JSON.stringify({
|
|
90
|
+
message: fs.readFileSync('dist/message.txt', 'utf8').trim(),
|
|
91
|
+
secret: process.env.OWNER_SECRET || null,
|
|
92
|
+
bridgeToken: process.env.WENRWA_CONNECT_TOKEN || null,
|
|
93
|
+
relayToken: process.env.RELAY_TOKEN || null,
|
|
94
|
+
gitToken: process.env.WENRWA_GIT_TOKEN || null,
|
|
95
|
+
port: process.env.PORT,
|
|
96
|
+
}));
|
|
97
|
+
}).listen(Number(process.env.PORT), process.env.HOST);
|
|
98
|
+
`;
|
|
99
|
+
// A server that ignores HOST and listens on every interface.
|
|
100
|
+
const WILD_SERVER_JS = `
|
|
101
|
+
require('http').createServer((req, res) => res.end('exposed')).listen(Number(process.env.PORT));
|
|
102
|
+
`;
|
|
103
|
+
function makeFixture() {
|
|
104
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tp05-'));
|
|
105
|
+
const remote = path.join(root, 'remote.git');
|
|
106
|
+
const author = path.join(root, 'author');
|
|
107
|
+
git(root, 'init', '--bare', '-b', 'main', remote);
|
|
108
|
+
fs.mkdirSync(author);
|
|
109
|
+
git(author, 'init', '-b', 'main');
|
|
110
|
+
fs.writeFileSync(path.join(author, 'package.json'), JSON.stringify({
|
|
111
|
+
name: 'fx', version: '1.0.0', private: true,
|
|
112
|
+
scripts: { build: 'node build.js', start: 'node server.js', dev: 'node build.js && node server.js' },
|
|
113
|
+
}));
|
|
114
|
+
fs.writeFileSync(path.join(author, 'package-lock.json'), JSON.stringify({
|
|
115
|
+
name: 'fx', version: '1.0.0', lockfileVersion: 3, requires: true,
|
|
116
|
+
packages: { '': { name: 'fx', version: '1.0.0' } },
|
|
117
|
+
}));
|
|
118
|
+
fs.writeFileSync(path.join(author, 'build.js'), BUILD_JS);
|
|
119
|
+
fs.writeFileSync(path.join(author, 'server.js'), SERVER_JS);
|
|
120
|
+
fs.writeFileSync(path.join(author, 'server-wild.js'), WILD_SERVER_JS);
|
|
121
|
+
fs.writeFileSync(path.join(author, 'message.txt'), 'v1 from main\n');
|
|
122
|
+
fs.writeFileSync(path.join(author, '.gitignore'), '.env\ndist\nnode_modules\n');
|
|
123
|
+
git(author, 'add', '-A');
|
|
124
|
+
git(author, 'commit', '-m', 'v1');
|
|
125
|
+
git(author, 'remote', 'add', 'origin', remote);
|
|
126
|
+
git(author, 'push', 'origin', 'main');
|
|
127
|
+
// The owner's working copy: a separate clone with their gitignored .env.
|
|
128
|
+
const workingCopy = path.join(root, 'repos', 'ws-1');
|
|
129
|
+
fs.mkdirSync(path.dirname(workingCopy), { recursive: true });
|
|
130
|
+
git(root, 'clone', remote, workingCopy);
|
|
131
|
+
fs.writeFileSync(path.join(workingCopy, '.env'), 'OWNER_SECRET=sk_live_owner_value\n# a comment\nWENRWA_CONNECT_TOKEN=from-dotenv-refused\n');
|
|
132
|
+
return { root, remote, author, workingCopy, previews: path.join(root, 'previews') };
|
|
133
|
+
}
|
|
134
|
+
function pushChange(fx, files, msg) {
|
|
135
|
+
for (const [name, content] of Object.entries(files)) {
|
|
136
|
+
const p = path.join(fx.author, name);
|
|
137
|
+
if (content === null)
|
|
138
|
+
fs.rmSync(p, { force: true });
|
|
139
|
+
else
|
|
140
|
+
fs.writeFileSync(p, content);
|
|
141
|
+
}
|
|
142
|
+
git(fx.author, 'add', '-A');
|
|
143
|
+
git(fx.author, 'commit', '-m', msg);
|
|
144
|
+
git(fx.author, 'push', 'origin', 'main');
|
|
145
|
+
return git(fx.author, 'rev-parse', 'HEAD').trim();
|
|
146
|
+
}
|
|
147
|
+
describe('PreviewRunner (tp-05) — real git, real processes', () => {
|
|
148
|
+
let fx;
|
|
149
|
+
let port;
|
|
150
|
+
let runner;
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
fx = makeFixture();
|
|
153
|
+
port = await freePort();
|
|
154
|
+
runner = new previewCheckout_1.PreviewRunner({
|
|
155
|
+
previewsBaseDir: fx.previews,
|
|
156
|
+
workingCopyDir: (ws) => path.join(fx.root, 'repos', ws),
|
|
157
|
+
port,
|
|
158
|
+
// The bridge's own env carries HiveOp credentials; none may reach the app.
|
|
159
|
+
baseEnv: { ...process.env, WENRWA_CONNECT_TOKEN: 'bridge-connect-token', RELAY_TOKEN: 'bridge-relay-token' },
|
|
160
|
+
readyTimeoutMs: { built: 20_000, dev: 20_000 },
|
|
161
|
+
probeIntervalMs: 100,
|
|
162
|
+
killGraceMs: 1_000,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
afterEach(async () => {
|
|
166
|
+
await runner.shutdown();
|
|
167
|
+
fs.rmSync(fx.root, { recursive: true, force: true });
|
|
168
|
+
});
|
|
169
|
+
it("serves the start script on the preview port with the owner's .env, and none of the bridge's secrets", async () => {
|
|
170
|
+
const r = await runner.start({
|
|
171
|
+
workspaceId: 'ws-1',
|
|
172
|
+
repoUrl: fx.remote,
|
|
173
|
+
gitCredential: { token: 'git-token-must-not-reach-app', repoFullName: 'o/r' },
|
|
174
|
+
});
|
|
175
|
+
const head = git(fx.author, 'rev-parse', 'HEAD').trim();
|
|
176
|
+
expect(r).toMatchObject({ status: 'running', mode: 'built', sha: head, runningSha: head, builtSha: head });
|
|
177
|
+
const body = JSON.parse(await get(port));
|
|
178
|
+
expect(body).toEqual({
|
|
179
|
+
message: 'v1 from main',
|
|
180
|
+
secret: 'sk_live_owner_value',
|
|
181
|
+
bridgeToken: null,
|
|
182
|
+
relayToken: null,
|
|
183
|
+
gitToken: null,
|
|
184
|
+
port: String(port),
|
|
185
|
+
});
|
|
186
|
+
// A second start is answered as-is — no rebuild.
|
|
187
|
+
const again = await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
188
|
+
expect(again).toMatchObject({ status: 'running', runningSha: head });
|
|
189
|
+
});
|
|
190
|
+
it("never shows an uncommitted edit from the owner's working copy, and a merge updates it", async () => {
|
|
191
|
+
fs.writeFileSync(path.join(fx.workingCopy, 'message.txt'), 'UNCOMMITTED owner edit\n');
|
|
192
|
+
fs.writeFileSync(path.join(fx.workingCopy, 'scratch.txt'), 'untracked\n');
|
|
193
|
+
await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
194
|
+
expect(JSON.parse(await get(port)).message).toBe('v1 from main');
|
|
195
|
+
const v2 = pushChange(fx, { 'message.txt': 'v2 merged\n' }, 'v2');
|
|
196
|
+
const r = await runner.update({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
197
|
+
expect(r).toMatchObject({ status: 'running', sha: v2, runningSha: v2 });
|
|
198
|
+
expect(JSON.parse(await get(port)).message).toBe('v2 merged');
|
|
199
|
+
// The working copy itself was never touched.
|
|
200
|
+
expect(fs.readFileSync(path.join(fx.workingCopy, 'message.txt'), 'utf8')).toBe('UNCOMMITTED owner edit\n');
|
|
201
|
+
expect(fs.existsSync(path.join(fx.workingCopy, 'scratch.txt'))).toBe(true);
|
|
202
|
+
expect(git(fx.workingCopy, 'rev-parse', 'HEAD').trim()).not.toBe(v2);
|
|
203
|
+
});
|
|
204
|
+
it('a broken build keeps the old sha serving and reports build_failed', async () => {
|
|
205
|
+
const first = await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
206
|
+
const v1 = first.runningSha;
|
|
207
|
+
const broken = pushChange(fx, { BREAK_BUILD: 'x', 'message.txt': 'v2 broken\n' }, 'broken');
|
|
208
|
+
const r = await runner.update({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
209
|
+
expect(r).toMatchObject({ status: 'build_failed', sha: broken, runningSha: v1, builtSha: v1 });
|
|
210
|
+
expect(JSON.parse(await get(port)).message).toBe('v1 from main');
|
|
211
|
+
// The log shows the failure, on an owner request.
|
|
212
|
+
expect(runner.logs({ workspaceId: 'ws-1' }).log).toContain('build broke on purpose');
|
|
213
|
+
// Fixing main brings the preview forward again.
|
|
214
|
+
const fixed = pushChange(fx, { BREAK_BUILD: null, 'message.txt': 'v3 fixed\n' }, 'fix');
|
|
215
|
+
expect(await runner.update({ workspaceId: 'ws-1', repoUrl: fx.remote })).toMatchObject({ status: 'running', runningSha: fixed });
|
|
216
|
+
expect(JSON.parse(await get(port)).message).toBe('v3 fixed');
|
|
217
|
+
});
|
|
218
|
+
it('pins to the remote default branch — another branch pushed to the remote never runs', async () => {
|
|
219
|
+
git(fx.author, 'checkout', '-b', 'feature');
|
|
220
|
+
fs.writeFileSync(path.join(fx.author, 'message.txt'), 'feature branch\n');
|
|
221
|
+
git(fx.author, 'commit', '-am', 'feature');
|
|
222
|
+
git(fx.author, 'push', 'origin', 'feature');
|
|
223
|
+
await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
224
|
+
expect(JSON.parse(await get(port)).message).toBe('v1 from main');
|
|
225
|
+
});
|
|
226
|
+
it('stop frees the port; a second workspace cannot take the port while one serves', async () => {
|
|
227
|
+
await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
228
|
+
const busy = await runner.start({ workspaceId: 'ws-2', repoUrl: fx.remote });
|
|
229
|
+
expect(busy).toMatchObject({ status: 'failed', reason: 'busy_other_workspace' });
|
|
230
|
+
expect(await runner.stop({ workspaceId: 'ws-1' })).toMatchObject({ status: 'stopped' });
|
|
231
|
+
await expect(get(port)).rejects.toThrow();
|
|
232
|
+
});
|
|
233
|
+
it('SECURITY: the app listens on 127.0.0.1 only — no other address of this machine reaches it', async () => {
|
|
234
|
+
const external = (0, previewCheckout_1.machineExternalAddresses)();
|
|
235
|
+
expect(external.length).toBeGreaterThan(0); // the check below must mean something
|
|
236
|
+
await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
237
|
+
expect(JSON.parse(await get(port)).message).toBe('v1 from main');
|
|
238
|
+
expect(await (0, previewCheckout_1.listensBeyondLoopback)(port, external)).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
it('SECURITY: an app that binds every interface is refused unsupported_bind, and the old version keeps serving', async () => {
|
|
241
|
+
const first = await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
242
|
+
const wild = pushChange(fx, { 'package.json': JSON.stringify({
|
|
243
|
+
name: 'fx', version: '1.0.0', private: true,
|
|
244
|
+
scripts: { build: 'node build.js', start: 'node server-wild.js' },
|
|
245
|
+
}) }, 'wild bind');
|
|
246
|
+
const r = await runner.update({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
247
|
+
expect(r).toMatchObject({ status: 'failed', reason: 'unsupported_bind', sha: wild, runningSha: first.runningSha });
|
|
248
|
+
expect(JSON.parse(await get(port)).message).toBe('v1 from main');
|
|
249
|
+
expect(await (0, previewCheckout_1.listensBeyondLoopback)(port, (0, previewCheckout_1.machineExternalAddresses)())).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
it('SECURITY: an exposed app with nothing to fall back to is refused and not left running', async () => {
|
|
252
|
+
pushChange(fx, { 'package.json': JSON.stringify({
|
|
253
|
+
name: 'fx', version: '1.0.0', private: true,
|
|
254
|
+
scripts: { start: 'node server-wild.js' },
|
|
255
|
+
}) }, 'wild bind');
|
|
256
|
+
const r = await runner.start({ workspaceId: 'ws-1', repoUrl: fx.remote });
|
|
257
|
+
expect(r).toMatchObject({ status: 'failed', reason: 'unsupported_bind' });
|
|
258
|
+
expect(r.runningSha).toBeUndefined();
|
|
259
|
+
await expect(get(port)).rejects.toThrow();
|
|
260
|
+
});
|
|
261
|
+
it('a git failure reports failed / git_failed without starting anything', async () => {
|
|
262
|
+
const r = await runner.start({ workspaceId: 'ws-1', repoUrl: path.join(fx.root, 'nope.git') });
|
|
263
|
+
expect(r).toMatchObject({ status: 'failed', reason: 'git_failed' });
|
|
264
|
+
await expect(get(port)).rejects.toThrow();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
describe('preview pure helpers (tp-05)', () => {
|
|
268
|
+
it('buildPreviewEnv passes only plain process names, then the repo .env, then PORT', () => {
|
|
269
|
+
const env = (0, previewCheckout_1.buildPreviewEnv)({ PATH: '/bin', HOME: '/home/w', WENRWA_CONNECT_TOKEN: 'x', RELAY_TOKEN: 'y', ANTHROPIC_API_KEY: 'z' }, { OWNER_SECRET: 's', PORT: '9999' }, 4100);
|
|
270
|
+
expect(env).toEqual({ PATH: '/bin', HOME: '/home/w', OWNER_SECRET: 's', PORT: '4100', HOST: '127.0.0.1', HOSTNAME: '127.0.0.1' });
|
|
271
|
+
// The repo .env cannot widen the bind.
|
|
272
|
+
expect((0, previewCheckout_1.buildPreviewEnv)({}, { HOST: '0.0.0.0', HOSTNAME: '::' }, 4800)).toMatchObject({ HOST: '127.0.0.1', HOSTNAME: '127.0.0.1' });
|
|
273
|
+
});
|
|
274
|
+
it('loopbackArgs pins Next and Vite/Astro servers to 127.0.0.1 and leaves other scripts alone', () => {
|
|
275
|
+
expect((0, previewCheckout_1.loopbackArgs)('next start')).toEqual(['-H', '127.0.0.1']);
|
|
276
|
+
expect((0, previewCheckout_1.loopbackArgs)('next dev --turbo')).toEqual(['-H', '127.0.0.1']);
|
|
277
|
+
expect((0, previewCheckout_1.loopbackArgs)('vite preview')).toEqual(['--host', '127.0.0.1']);
|
|
278
|
+
expect((0, previewCheckout_1.loopbackArgs)('astro dev')).toEqual(['--host', '127.0.0.1']);
|
|
279
|
+
expect((0, previewCheckout_1.loopbackArgs)('node server.js')).toEqual([]);
|
|
280
|
+
expect((0, previewCheckout_1.loopbackArgs)('next build && next start')).toEqual([]);
|
|
281
|
+
});
|
|
282
|
+
it('parseSymrefHead reads the remote default branch and rejects junk', () => {
|
|
283
|
+
expect((0, previewCheckout_1.parseSymrefHead)('ref: refs/heads/main\tHEAD\nabc123\tHEAD\n')).toBe('main');
|
|
284
|
+
expect((0, previewCheckout_1.parseSymrefHead)('ref: refs/heads/release/v2\tHEAD')).toBe('release/v2');
|
|
285
|
+
expect((0, previewCheckout_1.parseSymrefHead)('abc123\tHEAD')).toBeNull();
|
|
286
|
+
expect((0, previewCheckout_1.parseSymrefHead)('ref: refs/heads/a..b\tHEAD')).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
it('installCommand follows the lockfile', () => {
|
|
289
|
+
expect((0, previewCheckout_1.installCommand)((f) => f === 'pnpm-lock.yaml')).toEqual(['pnpm', ['install', '--frozen-lockfile']]);
|
|
290
|
+
expect((0, previewCheckout_1.installCommand)((f) => f === 'yarn.lock')).toEqual(['yarn', ['install', '--frozen-lockfile']]);
|
|
291
|
+
expect((0, previewCheckout_1.installCommand)((f) => f === 'package-lock.json')).toEqual(['npm', ['ci']]);
|
|
292
|
+
expect((0, previewCheckout_1.installCommand)(() => false)).toEqual(['npm', ['install']]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|