Skip to content

Commit 4289f04

Browse files
Claudeclaude
authored andcommitted
feat: add internal admin CLI for license key management
Standalone script (not published to npm) for creating, listing, revoking, and managing license keys on the gitmem infra Supabase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b612fa8 commit 4289f04

1 file changed

Lines changed: 327 additions & 0 deletions

File tree

scripts/gitmem-admin.mjs

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* GitMem Admin — License Key Management (internal, not published)
5+
*
6+
* Usage:
7+
* node scripts/gitmem-admin.mjs create --email user@co.com [--tier pro] [--max-devices 3] [--expires YYYY-MM-DD]
8+
* node scripts/gitmem-admin.mjs list
9+
* node scripts/gitmem-admin.mjs revoke <key-prefix>
10+
* node scripts/gitmem-admin.mjs devices <key-prefix>
11+
* node scripts/gitmem-admin.mjs clear-devices <key-prefix>
12+
*
13+
* Requires SUPABASE_ACCESS_TOKEN (from `npx supabase login`).
14+
* Operates on the gitmem infrastructure Supabase project (cjptxyezuxdiinufgrrm).
15+
*/
16+
17+
import * as crypto from "crypto";
18+
import * as fs from "fs";
19+
import * as path from "path";
20+
21+
const INFRA_PROJECT_REF = "cjptxyezuxdiinufgrrm";
22+
const API_BASE = `https://api.supabase.com/v1/projects/${INFRA_PROJECT_REF}/database/query`;
23+
24+
function getAccessToken() {
25+
const envToken = process.env.SUPABASE_ACCESS_TOKEN;
26+
if (envToken) return envToken;
27+
28+
try {
29+
const tokenPath = path.join(
30+
process.env.HOME || process.env.USERPROFILE || "",
31+
".supabase",
32+
"access-token"
33+
);
34+
if (fs.existsSync(tokenPath)) {
35+
return fs.readFileSync(tokenPath, "utf-8").trim();
36+
}
37+
} catch {
38+
// No stored token
39+
}
40+
41+
console.error("Error: SUPABASE_ACCESS_TOKEN required.");
42+
console.error(" Set via environment variable or run: npx supabase login");
43+
process.exit(1);
44+
}
45+
46+
async function runQuery(token, sql) {
47+
const response = await fetch(API_BASE, {
48+
method: "POST",
49+
headers: {
50+
"Content-Type": "application/json",
51+
Authorization: `Bearer ${token}`,
52+
},
53+
body: JSON.stringify({ query: sql }),
54+
signal: AbortSignal.timeout(15_000),
55+
});
56+
57+
if (!response.ok) {
58+
const text = await response.text();
59+
console.error(`Database error (HTTP ${response.status}): ${text.slice(0, 300)}`);
60+
process.exit(1);
61+
}
62+
63+
return await response.json();
64+
}
65+
66+
function parseArgs(args) {
67+
const flags = {};
68+
const positional = [];
69+
70+
for (let i = 0; i < args.length; i++) {
71+
if (args[i].startsWith("--")) {
72+
const key = args[i].slice(2);
73+
const next = args[i + 1];
74+
if (next && !next.startsWith("--")) {
75+
flags[key] = next;
76+
i++;
77+
} else {
78+
flags[key] = "true";
79+
}
80+
} else {
81+
positional.push(args[i]);
82+
}
83+
}
84+
85+
return { flags, positional };
86+
}
87+
88+
function escapeSql(value) {
89+
return value.replace(/'/g, "''");
90+
}
91+
92+
// ─── Subcommands ─────────────────────────────────────────────
93+
94+
async function cmdCreate(token, args) {
95+
const { flags } = parseArgs(args);
96+
97+
const email = flags.email;
98+
if (!email) {
99+
console.error("Error: --email is required.");
100+
console.error(" Usage: node scripts/gitmem-admin.mjs create --email user@co.com [--tier pro] [--max-devices 3] [--expires YYYY-MM-DD]");
101+
process.exit(1);
102+
}
103+
104+
const tier = flags.tier || "pro";
105+
if (tier !== "pro" && tier !== "dev") {
106+
console.error("Error: --tier must be 'pro' or 'dev'.");
107+
process.exit(1);
108+
}
109+
110+
const maxDevices = parseInt(flags["max-devices"] || "3", 10);
111+
if (isNaN(maxDevices) || maxDevices < 1) {
112+
console.error("Error: --max-devices must be a positive integer.");
113+
process.exit(1);
114+
}
115+
116+
const expires = flags.expires || null;
117+
if (expires && !/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
118+
console.error("Error: --expires must be YYYY-MM-DD format.");
119+
process.exit(1);
120+
}
121+
122+
const key = `gitmem_${tier}_` + crypto.randomBytes(16).toString("hex");
123+
124+
const expiresClause = expires ? `'${escapeSql(expires)}'` : "NULL";
125+
126+
const sql = `
127+
INSERT INTO gitmem_licenses (api_key, tier, owner_email, max_activations, expires_at, is_active)
128+
VALUES ('${escapeSql(key)}', '${tier}', '${escapeSql(email)}', ${maxDevices}, ${expiresClause}, true)
129+
RETURNING id, api_key, tier, owner_email, max_activations, expires_at, created_at;
130+
`;
131+
132+
const rows = await runQuery(token, sql);
133+
134+
if (rows.length > 0) {
135+
const row = rows[0];
136+
console.log("");
137+
console.log("License created:");
138+
console.log(` Key: ${key}`);
139+
console.log(` Tier: ${row.tier}`);
140+
console.log(` Email: ${row.owner_email}`);
141+
console.log(` Max Devices: ${row.max_activations}`);
142+
console.log(` Expires: ${row.expires_at || "never"}`);
143+
console.log(` Created: ${row.created_at}`);
144+
console.log("");
145+
console.log("Share this key with the user:");
146+
console.log(` ${key}`);
147+
}
148+
}
149+
150+
async function cmdList(token) {
151+
const sql = `
152+
SELECT
153+
l.id,
154+
substring(l.api_key, 1, 20) as key_prefix,
155+
l.tier,
156+
l.owner_email,
157+
l.max_activations,
158+
l.is_active,
159+
l.expires_at,
160+
l.created_at,
161+
COUNT(a.id) as active_devices
162+
FROM gitmem_licenses l
163+
LEFT JOIN gitmem_license_activations a ON a.license_id = l.id
164+
GROUP BY l.id, l.api_key, l.tier, l.owner_email, l.max_activations, l.is_active, l.expires_at, l.created_at
165+
ORDER BY l.created_at DESC;
166+
`;
167+
168+
const rows = await runQuery(token, sql);
169+
170+
if (rows.length === 0) {
171+
console.log("No licenses found.");
172+
return;
173+
}
174+
175+
console.log("");
176+
console.log(`${"Key Prefix".padEnd(22)} ${"Tier".padEnd(5)} ${"Email".padEnd(30)} ${"Devices".padEnd(9)} ${"Active".padEnd(7)} ${"Expires".padEnd(12)} Created`);
177+
console.log("─".repeat(120));
178+
179+
for (const row of rows) {
180+
const prefix = String(row.key_prefix || "").padEnd(22);
181+
const tier = String(row.tier || "").padEnd(5);
182+
const email = String(row.owner_email || "").padEnd(30);
183+
const devices = `${row.active_devices}/${row.max_activations}`.padEnd(9);
184+
const active = (row.is_active ? "yes" : "NO").padEnd(7);
185+
const expires = (row.expires_at ? String(row.expires_at).slice(0, 10) : "never").padEnd(12);
186+
const created = String(row.created_at || "").slice(0, 10);
187+
console.log(`${prefix} ${tier} ${email} ${devices} ${active} ${expires} ${created}`);
188+
}
189+
190+
console.log("");
191+
console.log(`${rows.length} license(s)`);
192+
}
193+
194+
async function cmdRevoke(token, args) {
195+
const prefix = args[0];
196+
if (!prefix) {
197+
console.error("Error: key prefix required.");
198+
console.error(" Usage: node scripts/gitmem-admin.mjs revoke <key-prefix>");
199+
process.exit(1);
200+
}
201+
202+
const sql = `
203+
UPDATE gitmem_licenses
204+
SET is_active = false
205+
WHERE api_key LIKE '${escapeSql(prefix)}%'
206+
RETURNING id, substring(api_key, 1, 20) as key_prefix, owner_email;
207+
`;
208+
209+
const rows = await runQuery(token, sql);
210+
211+
if (rows.length === 0) {
212+
console.error(`No license found matching prefix: ${prefix}`);
213+
process.exit(1);
214+
}
215+
216+
for (const row of rows) {
217+
console.log(`Revoked: ${row.key_prefix}... (${row.owner_email})`);
218+
}
219+
}
220+
221+
async function cmdDevices(token, args) {
222+
const prefix = args[0];
223+
if (!prefix) {
224+
console.error("Error: key prefix required.");
225+
console.error(" Usage: node scripts/gitmem-admin.mjs devices <key-prefix>");
226+
process.exit(1);
227+
}
228+
229+
const sql = `
230+
SELECT
231+
a.install_id,
232+
a.activated_at,
233+
a.last_seen_at
234+
FROM gitmem_license_activations a
235+
JOIN gitmem_licenses l ON l.id = a.license_id
236+
WHERE l.api_key LIKE '${escapeSql(prefix)}%'
237+
ORDER BY a.last_seen_at DESC;
238+
`;
239+
240+
const rows = await runQuery(token, sql);
241+
242+
if (rows.length === 0) {
243+
console.log(`No device activations for prefix: ${prefix}`);
244+
return;
245+
}
246+
247+
console.log("");
248+
console.log(`${"Install ID".padEnd(38)} ${"Activated".padEnd(22)} Last Seen`);
249+
console.log("─".repeat(90));
250+
251+
for (const row of rows) {
252+
const installId = String(row.install_id || "").padEnd(38);
253+
const activated = String(row.activated_at || "").slice(0, 19).padEnd(22);
254+
const lastSeen = String(row.last_seen_at || "").slice(0, 19);
255+
console.log(`${installId} ${activated} ${lastSeen}`);
256+
}
257+
258+
console.log("");
259+
console.log(`${rows.length} device(s)`);
260+
}
261+
262+
async function cmdClearDevices(token, args) {
263+
const prefix = args[0];
264+
if (!prefix) {
265+
console.error("Error: key prefix required.");
266+
console.error(" Usage: node scripts/gitmem-admin.mjs clear-devices <key-prefix>");
267+
process.exit(1);
268+
}
269+
270+
const sql = `
271+
DELETE FROM gitmem_license_activations
272+
WHERE license_id IN (
273+
SELECT id FROM gitmem_licenses WHERE api_key LIKE '${escapeSql(prefix)}%'
274+
)
275+
RETURNING id;
276+
`;
277+
278+
const rows = await runQuery(token, sql);
279+
console.log(`Cleared ${rows.length} device activation(s) for prefix: ${prefix}`);
280+
}
281+
282+
// ─── Main ────────────────────────────────────────────────────
283+
284+
const args = process.argv.slice(2);
285+
const subcommand = args[0];
286+
const subArgs = args.slice(1);
287+
288+
if (!subcommand || subcommand === "help" || subcommand === "--help") {
289+
console.log(`
290+
GitMem Admin — License Key Management (internal)
291+
292+
Usage:
293+
node scripts/gitmem-admin.mjs create --email user@co.com [--tier pro] [--max-devices 3] [--expires YYYY-MM-DD]
294+
node scripts/gitmem-admin.mjs list
295+
node scripts/gitmem-admin.mjs revoke <key-prefix>
296+
node scripts/gitmem-admin.mjs devices <key-prefix>
297+
node scripts/gitmem-admin.mjs clear-devices <key-prefix>
298+
299+
Requires SUPABASE_ACCESS_TOKEN (from \`npx supabase login\`).
300+
Operates on infra project: ${INFRA_PROJECT_REF}
301+
`);
302+
process.exit(0);
303+
}
304+
305+
const token = getAccessToken();
306+
307+
switch (subcommand) {
308+
case "create":
309+
await cmdCreate(token, subArgs);
310+
break;
311+
case "list":
312+
await cmdList(token);
313+
break;
314+
case "revoke":
315+
await cmdRevoke(token, subArgs);
316+
break;
317+
case "devices":
318+
await cmdDevices(token, subArgs);
319+
break;
320+
case "clear-devices":
321+
await cmdClearDevices(token, subArgs);
322+
break;
323+
default:
324+
console.error(`Unknown subcommand: ${subcommand}`);
325+
console.error(" Run: node scripts/gitmem-admin.mjs help");
326+
process.exit(1);
327+
}

0 commit comments

Comments
 (0)