checkpoint-cli 0.1.0
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/config.d.ts +13 -0
- package/dist/config.js +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +666 -0
- package/package.json +29 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const SUPABASE_URL = "https://uxunzukbuaffsnavvxub.supabase.co";
|
|
2
|
+
export declare const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV4dW56dWtidWFmZnNuYXZ2eHViIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5MTE3NzMsImV4cCI6MjA4NjQ4Nzc3M30.XYQNXTfqClvcr9jwdH2e8ySiG5Ue85YQAoWd1c7CxVk";
|
|
3
|
+
/** Default app URL — can be overridden per environment */
|
|
4
|
+
export declare const DEFAULT_APP_URL = "https://checkpoint.build";
|
|
5
|
+
export interface StoredConfig {
|
|
6
|
+
access_token: string;
|
|
7
|
+
refresh_token: string;
|
|
8
|
+
app_url?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function loadConfig(): StoredConfig | null;
|
|
11
|
+
export declare function saveConfig(config: StoredConfig): void;
|
|
12
|
+
export declare function clearConfig(): void;
|
|
13
|
+
export declare function getAppUrl(): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULT_APP_URL = exports.SUPABASE_ANON_KEY = exports.SUPABASE_URL = void 0;
|
|
7
|
+
exports.loadConfig = loadConfig;
|
|
8
|
+
exports.saveConfig = saveConfig;
|
|
9
|
+
exports.clearConfig = clearConfig;
|
|
10
|
+
exports.getAppUrl = getAppUrl;
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
14
|
+
/* ── Baked-in public config ── */
|
|
15
|
+
exports.SUPABASE_URL = 'https://uxunzukbuaffsnavvxub.supabase.co';
|
|
16
|
+
exports.SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV4dW56dWtidWFmZnNuYXZ2eHViIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5MTE3NzMsImV4cCI6MjA4NjQ4Nzc3M30.XYQNXTfqClvcr9jwdH2e8ySiG5Ue85YQAoWd1c7CxVk';
|
|
17
|
+
/** Default app URL — can be overridden per environment */
|
|
18
|
+
exports.DEFAULT_APP_URL = 'https://checkpoint.build';
|
|
19
|
+
/* ── Local token storage (~/.checkpoint/config.json) ── */
|
|
20
|
+
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.checkpoint');
|
|
21
|
+
const CONFIG_FILE = path_1.default.join(CONFIG_DIR, 'config.json');
|
|
22
|
+
function loadConfig() {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs_1.default.existsSync(CONFIG_FILE))
|
|
25
|
+
return null;
|
|
26
|
+
const raw = fs_1.default.readFileSync(CONFIG_FILE, 'utf-8');
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (!parsed.access_token || !parsed.refresh_token)
|
|
29
|
+
return null;
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function saveConfig(config) {
|
|
37
|
+
if (!fs_1.default.existsSync(CONFIG_DIR)) {
|
|
38
|
+
fs_1.default.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
39
|
+
}
|
|
40
|
+
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
|
41
|
+
mode: 0o600, // Owner read/write only
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function clearConfig() {
|
|
45
|
+
try {
|
|
46
|
+
if (fs_1.default.existsSync(CONFIG_FILE))
|
|
47
|
+
fs_1.default.unlinkSync(CONFIG_FILE);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Ignore
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function getAppUrl() {
|
|
54
|
+
// Allow override via env var or stored config, fallback to default
|
|
55
|
+
return (process.env.CHECKPOINT_APP_URL ||
|
|
56
|
+
loadConfig()?.app_url ||
|
|
57
|
+
exports.DEFAULT_APP_URL);
|
|
58
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const child_process_1 = require("child_process");
|
|
9
|
+
const http_1 = __importDefault(require("http"));
|
|
10
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
11
|
+
const supabase_js_1 = require("@supabase/supabase-js");
|
|
12
|
+
const config_js_1 = require("./config.js");
|
|
13
|
+
/* ── Authenticated Supabase Client ── */
|
|
14
|
+
function getSupabase() {
|
|
15
|
+
const config = (0, config_js_1.loadConfig)();
|
|
16
|
+
if (!config)
|
|
17
|
+
return null;
|
|
18
|
+
return (0, supabase_js_1.createClient)(config_js_1.SUPABASE_URL, config_js_1.SUPABASE_ANON_KEY, {
|
|
19
|
+
global: {
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${config.access_token}`,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
auth: {
|
|
25
|
+
autoRefreshToken: false,
|
|
26
|
+
persistSession: false,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Get an authenticated Supabase client, refreshing the token if needed. */
|
|
31
|
+
async function getAuthenticatedClient() {
|
|
32
|
+
const config = (0, config_js_1.loadConfig)();
|
|
33
|
+
if (!config)
|
|
34
|
+
return null;
|
|
35
|
+
const sb = (0, supabase_js_1.createClient)(config_js_1.SUPABASE_URL, config_js_1.SUPABASE_ANON_KEY, {
|
|
36
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
37
|
+
});
|
|
38
|
+
// Set the session from stored tokens
|
|
39
|
+
const { data, error } = await sb.auth.setSession({
|
|
40
|
+
access_token: config.access_token,
|
|
41
|
+
refresh_token: config.refresh_token,
|
|
42
|
+
});
|
|
43
|
+
if (error || !data.session) {
|
|
44
|
+
// Try refreshing
|
|
45
|
+
const { data: refreshData, error: refreshError } = await sb.auth.refreshSession({
|
|
46
|
+
refresh_token: config.refresh_token,
|
|
47
|
+
});
|
|
48
|
+
if (refreshError || !refreshData.session) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
// Save refreshed tokens
|
|
52
|
+
(0, config_js_1.saveConfig)({
|
|
53
|
+
...config,
|
|
54
|
+
access_token: refreshData.session.access_token,
|
|
55
|
+
refresh_token: refreshData.session.refresh_token,
|
|
56
|
+
});
|
|
57
|
+
return sb;
|
|
58
|
+
}
|
|
59
|
+
// Update tokens if they changed
|
|
60
|
+
if (data.session.access_token !== config.access_token) {
|
|
61
|
+
(0, config_js_1.saveConfig)({
|
|
62
|
+
...config,
|
|
63
|
+
access_token: data.session.access_token,
|
|
64
|
+
refresh_token: data.session.refresh_token,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return sb;
|
|
68
|
+
}
|
|
69
|
+
function requireAuth() {
|
|
70
|
+
const config = (0, config_js_1.loadConfig)();
|
|
71
|
+
if (!config) {
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(chalk_1.default.red(' Not logged in.'));
|
|
74
|
+
console.log('');
|
|
75
|
+
console.log(` Run ${chalk_1.default.cyan('checkpoint login')} first.`);
|
|
76
|
+
console.log('');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/* ── Checkpoint Tracking Script (injected into HTML responses) ── */
|
|
81
|
+
function trackingScript(checkpointOrigin) {
|
|
82
|
+
return `
|
|
83
|
+
<script data-checkpoint>
|
|
84
|
+
(function(){
|
|
85
|
+
var origin=${JSON.stringify(checkpointOrigin)};
|
|
86
|
+
function report(){
|
|
87
|
+
try{
|
|
88
|
+
window.parent.postMessage({
|
|
89
|
+
type:'checkpoint:navigate',
|
|
90
|
+
path:location.pathname+location.search
|
|
91
|
+
},origin);
|
|
92
|
+
}catch(e){}
|
|
93
|
+
}
|
|
94
|
+
report();
|
|
95
|
+
var _ps=history.pushState,_rs=history.replaceState;
|
|
96
|
+
history.pushState=function(){_ps.apply(this,arguments);report();};
|
|
97
|
+
history.replaceState=function(){_rs.apply(this,arguments);report();};
|
|
98
|
+
window.addEventListener('popstate',report);
|
|
99
|
+
})();
|
|
100
|
+
</script>`.trim();
|
|
101
|
+
}
|
|
102
|
+
/* ── Injection Proxy ── */
|
|
103
|
+
function startInjectionProxy(targetPort) {
|
|
104
|
+
const APP_URL = (0, config_js_1.getAppUrl)();
|
|
105
|
+
const SCRIPT = trackingScript(APP_URL);
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const server = http_1.default.createServer((req, res) => {
|
|
108
|
+
const fwdHeaders = { ...req.headers, host: `localhost:${targetPort}` };
|
|
109
|
+
delete fwdHeaders['accept-encoding'];
|
|
110
|
+
const proxyReq = http_1.default.request({
|
|
111
|
+
hostname: 'localhost',
|
|
112
|
+
port: targetPort,
|
|
113
|
+
path: req.url,
|
|
114
|
+
method: req.method,
|
|
115
|
+
headers: fwdHeaders,
|
|
116
|
+
}, (proxyRes) => {
|
|
117
|
+
const contentType = proxyRes.headers['content-type'] || '';
|
|
118
|
+
const isHtml = contentType.includes('text/html');
|
|
119
|
+
if (!isHtml) {
|
|
120
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
121
|
+
proxyRes.pipe(res);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const chunks = [];
|
|
125
|
+
proxyRes.on('data', (chunk) => chunks.push(chunk));
|
|
126
|
+
proxyRes.on('end', () => {
|
|
127
|
+
let body = Buffer.concat(chunks).toString('utf-8');
|
|
128
|
+
if (!body.includes('data-checkpoint')) {
|
|
129
|
+
if (body.includes('</head>')) {
|
|
130
|
+
body = body.replace('</head>', SCRIPT + '\n</head>');
|
|
131
|
+
}
|
|
132
|
+
else if (body.includes('</body>')) {
|
|
133
|
+
body = body.replace('</body>', SCRIPT + '\n</body>');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
body += SCRIPT;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const headers = { ...proxyRes.headers };
|
|
140
|
+
delete headers['content-length'];
|
|
141
|
+
delete headers['transfer-encoding'];
|
|
142
|
+
headers['content-length'] = String(Buffer.byteLength(body));
|
|
143
|
+
res.writeHead(proxyRes.statusCode || 200, headers);
|
|
144
|
+
res.end(body);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
proxyReq.on('error', (err) => {
|
|
148
|
+
res.writeHead(502);
|
|
149
|
+
res.end(`Checkpoint proxy error: ${err.message}`);
|
|
150
|
+
});
|
|
151
|
+
req.pipe(proxyReq);
|
|
152
|
+
});
|
|
153
|
+
server.on('upgrade', (req, socket, head) => {
|
|
154
|
+
const proxyReq = http_1.default.request({
|
|
155
|
+
hostname: 'localhost',
|
|
156
|
+
port: targetPort,
|
|
157
|
+
path: req.url,
|
|
158
|
+
method: req.method,
|
|
159
|
+
headers: { ...req.headers, host: `localhost:${targetPort}` },
|
|
160
|
+
});
|
|
161
|
+
proxyReq.on('upgrade', (_proxyRes, proxySocket, proxyHead) => {
|
|
162
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\n` +
|
|
163
|
+
Object.entries(_proxyRes.headers)
|
|
164
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
165
|
+
.join('\r\n') +
|
|
166
|
+
'\r\n\r\n');
|
|
167
|
+
if (proxyHead.length)
|
|
168
|
+
socket.write(proxyHead);
|
|
169
|
+
proxySocket.pipe(socket);
|
|
170
|
+
socket.pipe(proxySocket);
|
|
171
|
+
});
|
|
172
|
+
proxyReq.on('error', () => socket.destroy());
|
|
173
|
+
socket.on('error', () => proxyReq.destroy());
|
|
174
|
+
proxyReq.end(head);
|
|
175
|
+
});
|
|
176
|
+
server.listen(0, '127.0.0.1', () => {
|
|
177
|
+
const addr = server.address();
|
|
178
|
+
if (!addr || typeof addr === 'string') {
|
|
179
|
+
reject(new Error('Failed to start injection proxy'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
resolve({ proxyPort: addr.port, close: () => server.close() });
|
|
183
|
+
});
|
|
184
|
+
server.on('error', reject);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/* ── Helpers ── */
|
|
188
|
+
function generateShareId() {
|
|
189
|
+
const chars = 'abcdefghijkmnpqrstuvwxyz23456789';
|
|
190
|
+
let result = '';
|
|
191
|
+
for (let i = 0; i < 8; i++) {
|
|
192
|
+
result += chars[Math.floor(Math.random() * chars.length)];
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
function hasBinary(name) {
|
|
197
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name))
|
|
198
|
+
return false;
|
|
199
|
+
try {
|
|
200
|
+
(0, child_process_1.execSync)(`which ${name}`, { stdio: 'ignore' });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const VALID_PROVIDERS = ['cloudflared', 'ngrok'];
|
|
208
|
+
function isValidProvider(provider) {
|
|
209
|
+
return VALID_PROVIDERS.includes(provider);
|
|
210
|
+
}
|
|
211
|
+
function isValidUrl(url) {
|
|
212
|
+
try {
|
|
213
|
+
const parsed = new URL(url);
|
|
214
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function detectProvider() {
|
|
221
|
+
if (hasBinary('cloudflared'))
|
|
222
|
+
return 'cloudflared';
|
|
223
|
+
if (hasBinary('ngrok'))
|
|
224
|
+
return 'ngrok';
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
/* ── Tunnel Launchers ── */
|
|
228
|
+
function launchCloudflared(port) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const child = (0, child_process_1.spawn)('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
231
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
232
|
+
});
|
|
233
|
+
let resolved = false;
|
|
234
|
+
const timeout = setTimeout(() => {
|
|
235
|
+
if (!resolved) {
|
|
236
|
+
reject(new Error('Timed out waiting for cloudflared (30s)'));
|
|
237
|
+
child.kill();
|
|
238
|
+
}
|
|
239
|
+
}, 30000);
|
|
240
|
+
const handleOutput = (data) => {
|
|
241
|
+
const match = data.toString().match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
242
|
+
if (match && !resolved) {
|
|
243
|
+
resolved = true;
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
resolve({ url: match[0], kill: () => child.kill('SIGTERM') });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
child.stdout.on('data', handleOutput);
|
|
249
|
+
child.stderr.on('data', handleOutput);
|
|
250
|
+
child.on('error', (err) => { clearTimeout(timeout); reject(new Error(`cloudflared: ${err.message}`)); });
|
|
251
|
+
child.on('exit', (code) => { if (!resolved) {
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
reject(new Error(`cloudflared exited ${code}`));
|
|
254
|
+
} });
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function launchNgrok(port) {
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
const child = (0, child_process_1.spawn)('ngrok', ['http', String(port), '--log', 'stdout', '--log-format', 'json'], {
|
|
260
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
261
|
+
});
|
|
262
|
+
let resolved = false;
|
|
263
|
+
const timeout = setTimeout(() => {
|
|
264
|
+
if (!resolved) {
|
|
265
|
+
reject(new Error('Timed out waiting for ngrok (15s)'));
|
|
266
|
+
child.kill();
|
|
267
|
+
}
|
|
268
|
+
}, 15000);
|
|
269
|
+
child.stdout.on('data', (data) => {
|
|
270
|
+
const match = data.toString().match(/https:\/\/[a-z0-9-]+\.ngrok[a-z.-]*\.(?:io|app|dev)[^\s"]*/);
|
|
271
|
+
if (match && !resolved) {
|
|
272
|
+
resolved = true;
|
|
273
|
+
clearTimeout(timeout);
|
|
274
|
+
resolve({ url: match[0], kill: () => child.kill('SIGTERM') });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
child.on('error', (err) => { clearTimeout(timeout); reject(new Error(`ngrok: ${err.message}`)); });
|
|
278
|
+
child.on('exit', (code) => { if (!resolved) {
|
|
279
|
+
clearTimeout(timeout);
|
|
280
|
+
reject(new Error(`ngrok exited ${code}`));
|
|
281
|
+
} });
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async function registerTunnel(sb, opts) {
|
|
285
|
+
if (!isValidUrl(opts.tunnelUrl)) {
|
|
286
|
+
console.log(chalk_1.default.yellow(' ⚠ Invalid tunnel URL'));
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const { data: userData } = await sb.auth.getUser();
|
|
290
|
+
if (!userData.user) {
|
|
291
|
+
console.log(chalk_1.default.yellow(' ⚠ Session expired. Run `checkpoint login` again.'));
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const APP_URL = (0, config_js_1.getAppUrl)();
|
|
295
|
+
const shareId = generateShareId();
|
|
296
|
+
const { data, error } = await sb.from('tunnels').insert({
|
|
297
|
+
name: opts.name,
|
|
298
|
+
local_port: opts.port,
|
|
299
|
+
tunnel_url: opts.tunnelUrl,
|
|
300
|
+
share_id: shareId,
|
|
301
|
+
status: 'active',
|
|
302
|
+
user_id: userData.user.id,
|
|
303
|
+
last_seen_at: new Date().toISOString(),
|
|
304
|
+
}).select('id').single();
|
|
305
|
+
if (error || !data) {
|
|
306
|
+
console.log(chalk_1.default.yellow(` ⚠ Could not register: ${error?.message ?? 'unknown'}`));
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return { shareUrl: `${APP_URL}/share/${shareId}`, tunnelId: data.id };
|
|
310
|
+
}
|
|
311
|
+
async function deactivateTunnel(sb, tunnelId) {
|
|
312
|
+
try {
|
|
313
|
+
await sb.from('tunnels')
|
|
314
|
+
.update({ status: 'inactive', last_seen_at: new Date().toISOString() })
|
|
315
|
+
.eq('id', tunnelId);
|
|
316
|
+
}
|
|
317
|
+
catch { /* best-effort */ }
|
|
318
|
+
}
|
|
319
|
+
function startHeartbeat(sb, tunnelId) {
|
|
320
|
+
return setInterval(async () => {
|
|
321
|
+
try {
|
|
322
|
+
await sb.from('tunnels')
|
|
323
|
+
.update({ last_seen_at: new Date().toISOString() })
|
|
324
|
+
.eq('id', tunnelId);
|
|
325
|
+
}
|
|
326
|
+
catch { /* silent */ }
|
|
327
|
+
}, 30000);
|
|
328
|
+
}
|
|
329
|
+
/* ── Browser-based Login Flow ── */
|
|
330
|
+
function openBrowser(url) {
|
|
331
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
332
|
+
(0, child_process_1.spawn)(cmd, [url], { stdio: 'ignore', detached: true }).unref();
|
|
333
|
+
}
|
|
334
|
+
function loginWithBrowser() {
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const server = http_1.default.createServer((req, res) => {
|
|
337
|
+
const url = new URL(req.url || '/', `http://localhost`);
|
|
338
|
+
if (url.pathname === '/callback') {
|
|
339
|
+
const access_token = url.searchParams.get('access_token');
|
|
340
|
+
const refresh_token = url.searchParams.get('refresh_token');
|
|
341
|
+
if (access_token && refresh_token) {
|
|
342
|
+
// Success page
|
|
343
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
344
|
+
res.end(`
|
|
345
|
+
<html>
|
|
346
|
+
<body style="font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #0a0e17; color: #f1f3f5;">
|
|
347
|
+
<div style="text-align: center;">
|
|
348
|
+
<h1 style="font-size: 24px;">✓ Logged in to Checkpoint</h1>
|
|
349
|
+
<p style="color: #9ca3af;">You can close this tab and return to your terminal.</p>
|
|
350
|
+
</div>
|
|
351
|
+
</body>
|
|
352
|
+
</html>
|
|
353
|
+
`);
|
|
354
|
+
server.close();
|
|
355
|
+
resolve({ access_token, refresh_token });
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
359
|
+
res.end(`
|
|
360
|
+
<html>
|
|
361
|
+
<body style="font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #0a0e17; color: #f1f3f5;">
|
|
362
|
+
<div style="text-align: center;">
|
|
363
|
+
<h1 style="font-size: 24px; color: #ef4444;">Login failed</h1>
|
|
364
|
+
<p style="color: #9ca3af;">Missing tokens. Please try again.</p>
|
|
365
|
+
</div>
|
|
366
|
+
</body>
|
|
367
|
+
</html>
|
|
368
|
+
`);
|
|
369
|
+
server.close();
|
|
370
|
+
reject(new Error('Missing tokens in callback'));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
res.writeHead(404);
|
|
375
|
+
res.end('Not found');
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
server.listen(0, '127.0.0.1', () => {
|
|
379
|
+
const addr = server.address();
|
|
380
|
+
if (!addr || typeof addr === 'string') {
|
|
381
|
+
reject(new Error('Failed to start callback server'));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const callbackUrl = `http://localhost:${addr.port}/callback`;
|
|
385
|
+
const APP_URL = (0, config_js_1.getAppUrl)();
|
|
386
|
+
const loginUrl = `${APP_URL}/cli/auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
387
|
+
console.log(chalk_1.default.gray(` Opening browser...`));
|
|
388
|
+
console.log(` ${chalk_1.default.cyan(loginUrl)}`);
|
|
389
|
+
console.log('');
|
|
390
|
+
openBrowser(loginUrl);
|
|
391
|
+
});
|
|
392
|
+
// Timeout after 5 minutes
|
|
393
|
+
setTimeout(() => {
|
|
394
|
+
server.close();
|
|
395
|
+
reject(new Error('Login timed out (5 minutes). Please try again.'));
|
|
396
|
+
}, 5 * 60 * 1000);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/* ── CLI ── */
|
|
400
|
+
const program = new commander_1.Command();
|
|
401
|
+
program
|
|
402
|
+
.name('checkpoint')
|
|
403
|
+
.description('Share your localhost with reviewers — get visual feedback directly on the page')
|
|
404
|
+
.version('0.1.0');
|
|
405
|
+
// ── checkpoint login ──
|
|
406
|
+
program
|
|
407
|
+
.command('login')
|
|
408
|
+
.description('Authenticate with Checkpoint via the browser')
|
|
409
|
+
.action(async () => {
|
|
410
|
+
console.log('');
|
|
411
|
+
console.log(chalk_1.default.blue.bold(' ⚡ Checkpoint'));
|
|
412
|
+
console.log('');
|
|
413
|
+
// Check if already logged in
|
|
414
|
+
const existing = (0, config_js_1.loadConfig)();
|
|
415
|
+
if (existing) {
|
|
416
|
+
const sb = await getAuthenticatedClient();
|
|
417
|
+
if (sb) {
|
|
418
|
+
const { data } = await sb.auth.getUser();
|
|
419
|
+
if (data.user) {
|
|
420
|
+
console.log(chalk_1.default.green(` Already logged in as ${chalk_1.default.white(data.user.email)}`));
|
|
421
|
+
console.log(chalk_1.default.gray(` Run ${chalk_1.default.cyan('checkpoint logout')} to switch accounts.`));
|
|
422
|
+
console.log('');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const tokens = await loginWithBrowser();
|
|
429
|
+
(0, config_js_1.saveConfig)({
|
|
430
|
+
access_token: tokens.access_token,
|
|
431
|
+
refresh_token: tokens.refresh_token,
|
|
432
|
+
app_url: (0, config_js_1.getAppUrl)(),
|
|
433
|
+
});
|
|
434
|
+
// Verify the session
|
|
435
|
+
const sb = await getAuthenticatedClient();
|
|
436
|
+
if (sb) {
|
|
437
|
+
const { data } = await sb.auth.getUser();
|
|
438
|
+
console.log(chalk_1.default.green(` ✓ Logged in as ${chalk_1.default.white(data.user?.email)}`));
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
console.log(chalk_1.default.green(' ✓ Logged in'));
|
|
442
|
+
}
|
|
443
|
+
console.log('');
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
console.log(chalk_1.default.red(` ${err.message}`));
|
|
447
|
+
console.log('');
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
// ── checkpoint logout ──
|
|
452
|
+
program
|
|
453
|
+
.command('logout')
|
|
454
|
+
.description('Sign out and remove stored credentials')
|
|
455
|
+
.action(() => {
|
|
456
|
+
console.log('');
|
|
457
|
+
console.log(chalk_1.default.blue.bold(' ⚡ Checkpoint'));
|
|
458
|
+
console.log('');
|
|
459
|
+
(0, config_js_1.clearConfig)();
|
|
460
|
+
console.log(chalk_1.default.green(' ✓ Logged out'));
|
|
461
|
+
console.log('');
|
|
462
|
+
});
|
|
463
|
+
// ── checkpoint start ──
|
|
464
|
+
program
|
|
465
|
+
.command('start')
|
|
466
|
+
.description('Tunnel a local port and create a Checkpoint share link')
|
|
467
|
+
.requiredOption('-p, --port <port>', 'Local port to tunnel', '3000')
|
|
468
|
+
.option('-n, --name <name>', 'Name for this tunnel', 'My Tunnel')
|
|
469
|
+
.option('--provider <provider>', 'Tunnel provider: cloudflared (default) or ngrok')
|
|
470
|
+
.action(async (opts) => {
|
|
471
|
+
requireAuth();
|
|
472
|
+
const port = parseInt(opts.port, 10);
|
|
473
|
+
console.log('');
|
|
474
|
+
console.log(chalk_1.default.blue.bold(' ⚡ Checkpoint'));
|
|
475
|
+
console.log('');
|
|
476
|
+
// Authenticate
|
|
477
|
+
const sb = await getAuthenticatedClient();
|
|
478
|
+
if (!sb) {
|
|
479
|
+
console.log(chalk_1.default.red(' Session expired. Run `checkpoint login` again.'));
|
|
480
|
+
console.log('');
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
// Detect provider
|
|
484
|
+
if (opts.provider && !isValidProvider(opts.provider)) {
|
|
485
|
+
console.log(chalk_1.default.red(` Unknown provider: ${opts.provider}`));
|
|
486
|
+
console.log(chalk_1.default.gray(` Valid: ${VALID_PROVIDERS.join(', ')}`));
|
|
487
|
+
console.log('');
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
const provider = opts.provider || detectProvider();
|
|
491
|
+
if (!provider) {
|
|
492
|
+
console.log(chalk_1.default.red(' No tunnel provider found.'));
|
|
493
|
+
console.log('');
|
|
494
|
+
console.log(' Install one of:');
|
|
495
|
+
console.log(chalk_1.default.cyan(' brew install cloudflared') + chalk_1.default.gray(' (recommended, free)'));
|
|
496
|
+
console.log(chalk_1.default.cyan(' brew install ngrok'));
|
|
497
|
+
console.log('');
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
console.log(chalk_1.default.gray(` Provider: ${provider}`));
|
|
501
|
+
console.log(chalk_1.default.gray(` Tunneling localhost:${port}...`));
|
|
502
|
+
console.log('');
|
|
503
|
+
try {
|
|
504
|
+
const proxy = await startInjectionProxy(port);
|
|
505
|
+
console.log(chalk_1.default.green(' ✓ Proxy started') + chalk_1.default.gray(` (localhost:${proxy.proxyPort} → :${port})`));
|
|
506
|
+
const launcher = provider === 'cloudflared' ? launchCloudflared : launchNgrok;
|
|
507
|
+
const tunnel = await launcher(proxy.proxyPort);
|
|
508
|
+
console.log(chalk_1.default.green(' ✓ Tunnel established'));
|
|
509
|
+
console.log(` ${chalk_1.default.gray('Tunnel URL:')} ${chalk_1.default.cyan(tunnel.url)}`);
|
|
510
|
+
const registered = await registerTunnel(sb, { name: opts.name, port, tunnelUrl: tunnel.url });
|
|
511
|
+
let heartbeatTimer;
|
|
512
|
+
if (registered) {
|
|
513
|
+
console.log(` ${chalk_1.default.gray('Share URL:')} ${chalk_1.default.green.bold(registered.shareUrl)}`);
|
|
514
|
+
console.log('');
|
|
515
|
+
console.log(chalk_1.default.gray(' Share this URL with reviewers — they can comment directly on the page.'));
|
|
516
|
+
heartbeatTimer = startHeartbeat(sb, registered.tunnelId);
|
|
517
|
+
}
|
|
518
|
+
console.log('');
|
|
519
|
+
console.log(chalk_1.default.gray(' Press Ctrl+C to stop.'));
|
|
520
|
+
console.log('');
|
|
521
|
+
const cleanup = async () => {
|
|
522
|
+
console.log('');
|
|
523
|
+
console.log(chalk_1.default.gray(' Shutting down...'));
|
|
524
|
+
if (heartbeatTimer)
|
|
525
|
+
clearInterval(heartbeatTimer);
|
|
526
|
+
if (registered) {
|
|
527
|
+
await deactivateTunnel(sb, registered.tunnelId);
|
|
528
|
+
console.log(chalk_1.default.gray(' ✓ Tunnel deactivated'));
|
|
529
|
+
}
|
|
530
|
+
tunnel.kill();
|
|
531
|
+
proxy.close();
|
|
532
|
+
process.exit(0);
|
|
533
|
+
};
|
|
534
|
+
process.on('SIGINT', cleanup);
|
|
535
|
+
process.on('SIGTERM', cleanup);
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
console.error(chalk_1.default.red(` ${err.message}`));
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
// ── checkpoint share ──
|
|
543
|
+
program
|
|
544
|
+
.command('share')
|
|
545
|
+
.description('Register an existing tunnel URL with Checkpoint')
|
|
546
|
+
.requiredOption('-u, --url <url>', 'Your tunnel URL')
|
|
547
|
+
.option('-n, --name <name>', 'Name for this tunnel', 'My Tunnel')
|
|
548
|
+
.option('-p, --port <port>', 'Local port (for reference)', '3000')
|
|
549
|
+
.action(async (opts) => {
|
|
550
|
+
requireAuth();
|
|
551
|
+
console.log('');
|
|
552
|
+
console.log(chalk_1.default.blue.bold(' ⚡ Checkpoint'));
|
|
553
|
+
console.log('');
|
|
554
|
+
const sb = await getAuthenticatedClient();
|
|
555
|
+
if (!sb) {
|
|
556
|
+
console.log(chalk_1.default.red(' Session expired. Run `checkpoint login` again.'));
|
|
557
|
+
console.log('');
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
if (!isValidUrl(opts.url)) {
|
|
561
|
+
console.log(chalk_1.default.red(' Invalid URL — must start with http:// or https://'));
|
|
562
|
+
console.log('');
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
const { data: userData } = await sb.auth.getUser();
|
|
566
|
+
if (!userData.user) {
|
|
567
|
+
console.log(chalk_1.default.red(' Session expired. Run `checkpoint login` again.'));
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
const APP_URL = (0, config_js_1.getAppUrl)();
|
|
571
|
+
const shareId = generateShareId();
|
|
572
|
+
const { data, error } = await sb.from('tunnels').insert({
|
|
573
|
+
name: opts.name,
|
|
574
|
+
local_port: parseInt(opts.port, 10),
|
|
575
|
+
tunnel_url: opts.url,
|
|
576
|
+
share_id: shareId,
|
|
577
|
+
status: 'active',
|
|
578
|
+
user_id: userData.user.id,
|
|
579
|
+
last_seen_at: new Date().toISOString(),
|
|
580
|
+
}).select('id').single();
|
|
581
|
+
if (error || !data) {
|
|
582
|
+
console.log(chalk_1.default.red(` Failed: ${error?.message ?? 'unknown error'}`));
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
const shareUrl = `${APP_URL}/share/${shareId}`;
|
|
586
|
+
console.log(chalk_1.default.green(' ✓ Registered'));
|
|
587
|
+
console.log(` ${chalk_1.default.gray('Tunnel URL:')} ${chalk_1.default.cyan(opts.url)}`);
|
|
588
|
+
console.log(` ${chalk_1.default.gray('Share URL:')} ${chalk_1.default.green.bold(shareUrl)}`);
|
|
589
|
+
console.log('');
|
|
590
|
+
console.log(chalk_1.default.gray(' Press Ctrl+C to deactivate.'));
|
|
591
|
+
console.log('');
|
|
592
|
+
const heartbeatTimer = startHeartbeat(sb, data.id);
|
|
593
|
+
const cleanup = async () => {
|
|
594
|
+
console.log('');
|
|
595
|
+
clearInterval(heartbeatTimer);
|
|
596
|
+
await deactivateTunnel(sb, data.id);
|
|
597
|
+
console.log(chalk_1.default.gray(' ✓ Tunnel deactivated'));
|
|
598
|
+
process.exit(0);
|
|
599
|
+
};
|
|
600
|
+
process.on('SIGINT', cleanup);
|
|
601
|
+
process.on('SIGTERM', cleanup);
|
|
602
|
+
await new Promise(() => { });
|
|
603
|
+
});
|
|
604
|
+
// ── checkpoint status ──
|
|
605
|
+
program
|
|
606
|
+
.command('status')
|
|
607
|
+
.description('Check setup status')
|
|
608
|
+
.action(async () => {
|
|
609
|
+
console.log('');
|
|
610
|
+
console.log(chalk_1.default.blue.bold(' ⚡ Checkpoint — Status'));
|
|
611
|
+
console.log('');
|
|
612
|
+
// Auth status
|
|
613
|
+
const config = (0, config_js_1.loadConfig)();
|
|
614
|
+
if (config) {
|
|
615
|
+
const sb = await getAuthenticatedClient();
|
|
616
|
+
if (sb) {
|
|
617
|
+
const { data } = await sb.auth.getUser();
|
|
618
|
+
if (data.user) {
|
|
619
|
+
console.log(` ${chalk_1.default.green('✓')} ${chalk_1.default.white('Logged in'.padEnd(20))} ${chalk_1.default.green(data.user.email)}`);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
console.log(` ${chalk_1.default.yellow('!')} ${chalk_1.default.white('Auth'.padEnd(20))} ${chalk_1.default.yellow('session expired — run `checkpoint login`')}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
console.log(` ${chalk_1.default.gray('✗')} ${chalk_1.default.white('Auth'.padEnd(20))} ${chalk_1.default.gray('not logged in — run `checkpoint login`')}`);
|
|
628
|
+
}
|
|
629
|
+
// Providers
|
|
630
|
+
const providers = [
|
|
631
|
+
{ name: 'cloudflared', label: 'Cloudflare Tunnel', install: 'brew install cloudflared' },
|
|
632
|
+
{ name: 'ngrok', label: 'ngrok', install: 'brew install ngrok' },
|
|
633
|
+
];
|
|
634
|
+
for (const p of providers) {
|
|
635
|
+
const installed = hasBinary(p.name);
|
|
636
|
+
const icon = installed ? chalk_1.default.green('✓') : chalk_1.default.gray('✗');
|
|
637
|
+
const status = installed ? chalk_1.default.green('installed') : chalk_1.default.gray(`not found — ${chalk_1.default.cyan(p.install)}`);
|
|
638
|
+
console.log(` ${icon} ${chalk_1.default.white(p.label.padEnd(20))} ${status}`);
|
|
639
|
+
}
|
|
640
|
+
console.log('');
|
|
641
|
+
});
|
|
642
|
+
// ── checkpoint whoami ──
|
|
643
|
+
program
|
|
644
|
+
.command('whoami')
|
|
645
|
+
.description('Show the currently logged in user')
|
|
646
|
+
.action(async () => {
|
|
647
|
+
const config = (0, config_js_1.loadConfig)();
|
|
648
|
+
if (!config) {
|
|
649
|
+
console.log('');
|
|
650
|
+
console.log(chalk_1.default.gray(' Not logged in. Run `checkpoint login` first.'));
|
|
651
|
+
console.log('');
|
|
652
|
+
process.exit(1);
|
|
653
|
+
}
|
|
654
|
+
const sb = await getAuthenticatedClient();
|
|
655
|
+
if (!sb) {
|
|
656
|
+
console.log('');
|
|
657
|
+
console.log(chalk_1.default.yellow(' Session expired. Run `checkpoint login` again.'));
|
|
658
|
+
console.log('');
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
const { data } = await sb.auth.getUser();
|
|
662
|
+
console.log('');
|
|
663
|
+
console.log(` ${chalk_1.default.white(data.user?.email ?? 'Unknown')}`);
|
|
664
|
+
console.log('');
|
|
665
|
+
});
|
|
666
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "checkpoint-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Share your localhost with reviewers — get visual feedback directly on the page",
|
|
5
|
+
"keywords": ["checkpoint", "tunnel", "localhost", "share", "feedback", "review", "cloudflare", "ngrok"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"checkpoint": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "tsc"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@supabase/supabase-js": "^2.95.3",
|
|
19
|
+
"chalk": "^5.6.2",
|
|
20
|
+
"commander": "^14.0.3"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.2.3",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
}
|
|
29
|
+
}
|