antigravity-seo-kit 1.0.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/LICENSE +88 -0
- package/README.md +136 -0
- package/bin/cli.js +117 -0
- package/lib/api.js +207 -0
- package/lib/downloader.js +187 -0
- package/lib/fingerprint.js +68 -0
- package/lib/installer.js +446 -0
- package/lib/utils.js +254 -0
- package/package.json +34 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const zlib = require('zlib');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// ─── Download ───────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Download a file from URL and return as Buffer.
|
|
13
|
+
* Supports HTTP redirects (up to 5).
|
|
14
|
+
*/
|
|
15
|
+
function downloadBuffer(url, headers = {}, { timeout = 60000, maxRedirects = 5 } = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
if (maxRedirects <= 0) {
|
|
18
|
+
return reject(new Error('Too many redirects'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parsed = new URL(url);
|
|
22
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
23
|
+
|
|
24
|
+
const options = {
|
|
25
|
+
hostname: parsed.hostname,
|
|
26
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
27
|
+
path: parsed.pathname + parsed.search,
|
|
28
|
+
method: 'GET',
|
|
29
|
+
headers: {
|
|
30
|
+
'User-Agent': 'antigravity-seo-kit',
|
|
31
|
+
...headers,
|
|
32
|
+
},
|
|
33
|
+
timeout,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const req = transport.request(options, (res) => {
|
|
37
|
+
// Follow redirects
|
|
38
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
39
|
+
return downloadBuffer(res.headers.location, headers, { timeout, maxRedirects: maxRedirects - 1 })
|
|
40
|
+
.then(resolve)
|
|
41
|
+
.catch(reject);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (res.statusCode !== 200) {
|
|
45
|
+
let body = '';
|
|
46
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
47
|
+
res.on('end', () => {
|
|
48
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode} – ${body.substring(0, 300)}`));
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const chunks = [];
|
|
54
|
+
let totalSize = 0;
|
|
55
|
+
const maxSize = 50 * 1024 * 1024; // 50 MB safety limit
|
|
56
|
+
|
|
57
|
+
res.on('data', (chunk) => {
|
|
58
|
+
totalSize += chunk.length;
|
|
59
|
+
if (totalSize > maxSize) {
|
|
60
|
+
req.destroy();
|
|
61
|
+
reject(new Error('Download too large (>50 MB). Aborting.'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('error', (err) => {
|
|
71
|
+
if (err.code === 'ECONNREFUSED') {
|
|
72
|
+
reject(new Error('Cannot connect to asset server. Check your internet connection.'));
|
|
73
|
+
} else if (err.code === 'ENOTFOUND') {
|
|
74
|
+
reject(new Error('Asset server not found. DNS resolution failed.'));
|
|
75
|
+
} else {
|
|
76
|
+
reject(new Error(`Download error: ${err.message}`));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.on('timeout', () => {
|
|
81
|
+
req.destroy();
|
|
82
|
+
reject(new Error('Download timed out. Please try again.'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
req.end();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Tar.gz Extraction (pure Node.js, zero dependencies) ────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract a .tar.gz buffer into targetDir.
|
|
93
|
+
* Returns { count, files } where files is the list of extracted relative paths.
|
|
94
|
+
*/
|
|
95
|
+
function extractTarGz(gzBuffer, targetDir) {
|
|
96
|
+
const tarBuffer = zlib.gunzipSync(gzBuffer);
|
|
97
|
+
|
|
98
|
+
let offset = 0;
|
|
99
|
+
let count = 0;
|
|
100
|
+
const files = [];
|
|
101
|
+
|
|
102
|
+
while (offset + 512 <= tarBuffer.length) {
|
|
103
|
+
const header = tarBuffer.subarray(offset, offset + 512);
|
|
104
|
+
|
|
105
|
+
// End-of-archive: two consecutive 512-byte zero blocks
|
|
106
|
+
if (isZeroBlock(header)) break;
|
|
107
|
+
|
|
108
|
+
const name = readString(header, 0, 100);
|
|
109
|
+
const sizeOctal = readString(header, 124, 12);
|
|
110
|
+
const typeFlag = header[156];
|
|
111
|
+
const prefix = readString(header, 345, 155);
|
|
112
|
+
|
|
113
|
+
const fullName = (prefix ? `${prefix}/${name}` : name).replace(/\\/g, '/');
|
|
114
|
+
const size = parseInt(sizeOctal, 8) || 0;
|
|
115
|
+
|
|
116
|
+
offset += 512; // skip past header
|
|
117
|
+
|
|
118
|
+
if (!fullName || fullName === '.' || fullName === './') {
|
|
119
|
+
offset += Math.ceil(size / 512) * 512;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const targetPath = path.join(targetDir, fullName);
|
|
124
|
+
|
|
125
|
+
// Security: block path traversal attacks
|
|
126
|
+
const resolved = path.resolve(targetPath);
|
|
127
|
+
if (!resolved.startsWith(path.resolve(targetDir))) {
|
|
128
|
+
throw new Error(`Blocked path traversal in archive: ${fullName}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ASCII_5 = 53; // '5' = directory
|
|
132
|
+
const ASCII_0 = 48; // '0' = regular file
|
|
133
|
+
|
|
134
|
+
if (typeFlag === ASCII_5 || fullName.endsWith('/')) {
|
|
135
|
+
// Directory entry
|
|
136
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
137
|
+
} else if (typeFlag === ASCII_0 || typeFlag === 0) {
|
|
138
|
+
// Regular file
|
|
139
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
140
|
+
fs.writeFileSync(targetPath, tarBuffer.subarray(offset, offset + size));
|
|
141
|
+
files.push(fullName);
|
|
142
|
+
count++;
|
|
143
|
+
}
|
|
144
|
+
// Skip padding to next 512-byte boundary
|
|
145
|
+
offset += Math.ceil(size / 512) * 512;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { count, files };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isZeroBlock(buf) {
|
|
152
|
+
for (let i = 0; i < 512; i++) {
|
|
153
|
+
if (buf[i] !== 0) return false;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readString(buf, offset, length) {
|
|
159
|
+
let end = offset;
|
|
160
|
+
while (end < offset + length && buf[end] !== 0) end++;
|
|
161
|
+
return buf.subarray(offset, end).toString('utf8').trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── High-level API ─────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Download SEO Kit assets from server and extract to workspace.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} downloadUrl Full URL of the .tar.gz endpoint
|
|
170
|
+
* @param {string} licenseKey License key for authentication
|
|
171
|
+
* @param {string} targetDir Workspace root where .agent/ will be created
|
|
172
|
+
* @returns {Promise<{ count: number, files: string[] }>}
|
|
173
|
+
*/
|
|
174
|
+
async function downloadAndExtract(downloadUrl, licenseKey, targetDir) {
|
|
175
|
+
const buffer = await downloadBuffer(downloadUrl, {
|
|
176
|
+
'X-License-Key': licenseKey,
|
|
177
|
+
'Accept': 'application/gzip, application/octet-stream',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return extractTarGz(buffer, targetDir);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
downloadBuffer,
|
|
185
|
+
extractTarGz,
|
|
186
|
+
downloadAndExtract,
|
|
187
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a unique device fingerprint based on hardware characteristics.
|
|
8
|
+
*
|
|
9
|
+
* Combines hostname, platform, architecture, CPU model, total memory,
|
|
10
|
+
* and primary MAC address into a SHA-256 hash (truncated to 16 chars).
|
|
11
|
+
*
|
|
12
|
+
* This is not tamper-proof but sufficient for license device tracking.
|
|
13
|
+
*/
|
|
14
|
+
function generateFingerprint() {
|
|
15
|
+
const components = [
|
|
16
|
+
os.hostname(),
|
|
17
|
+
os.platform(),
|
|
18
|
+
os.arch(),
|
|
19
|
+
getCpuModel(),
|
|
20
|
+
os.totalmem().toString(),
|
|
21
|
+
getPrimaryMacAddress(),
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const raw = components.join('|');
|
|
25
|
+
return crypto.createHash('sha256').update(raw).digest('hex').substring(0, 16);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get a human-readable device name for display purposes.
|
|
30
|
+
* Example: "DESKTOP-ABC (win32 x64)"
|
|
31
|
+
*/
|
|
32
|
+
function getDeviceName() {
|
|
33
|
+
const hostname = os.hostname();
|
|
34
|
+
const platform = os.platform();
|
|
35
|
+
const arch = os.arch();
|
|
36
|
+
const release = os.release().split('.').slice(0, 2).join('.');
|
|
37
|
+
return `${hostname} (${platform} ${arch}, ${release})`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get CPU model string.
|
|
42
|
+
*/
|
|
43
|
+
function getCpuModel() {
|
|
44
|
+
const cpus = os.cpus();
|
|
45
|
+
return cpus.length > 0 ? cpus[0].model.trim() : 'unknown-cpu';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the MAC address of the first non-internal network interface.
|
|
50
|
+
* Falls back to 'no-mac' if none found.
|
|
51
|
+
*/
|
|
52
|
+
function getPrimaryMacAddress() {
|
|
53
|
+
const interfaces = os.networkInterfaces();
|
|
54
|
+
for (const name of Object.keys(interfaces)) {
|
|
55
|
+
for (const iface of interfaces[name]) {
|
|
56
|
+
// Skip internal (loopback) and link-local interfaces
|
|
57
|
+
if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') {
|
|
58
|
+
return iface.mac;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return 'no-mac';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
generateFingerprint,
|
|
67
|
+
getDeviceName,
|
|
68
|
+
};
|
package/lib/installer.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
success, error, warn, info, step, file, dir,
|
|
7
|
+
spinner, removeRecursive,
|
|
8
|
+
writeLicenseFile, removeLicenseFile, readLicenseFile,
|
|
9
|
+
colorize, PACKAGE_VERSION,
|
|
10
|
+
} = require('./utils');
|
|
11
|
+
const { generateFingerprint, getDeviceName } = require('./fingerprint');
|
|
12
|
+
const api = require('./api');
|
|
13
|
+
const { downloadAndExtract } = require('./downloader');
|
|
14
|
+
|
|
15
|
+
// ─── Legacy arrays (for uninstall/status of older installs without manifest) ─
|
|
16
|
+
|
|
17
|
+
const SEO_SKILL_DIRS = [
|
|
18
|
+
'seo', 'seo-agentic', 'seo-answer', 'seo-audience', 'seo-audit',
|
|
19
|
+
'seo-authority', 'seo-backlink', 'seo-brand-sentiment', 'seo-citemet',
|
|
20
|
+
'seo-coccoc-optimizer', 'seo-competitor-pages', 'seo-content',
|
|
21
|
+
'seo-content-gap', 'seo-dataforseo', 'seo-entity', 'seo-fan-out-generator',
|
|
22
|
+
'seo-fix', 'seo-geo', 'seo-geo-monitor', 'seo-google', 'seo-hreflang',
|
|
23
|
+
'seo-image-gen', 'seo-images', 'seo-keyword', 'seo-llmstxt', 'seo-local',
|
|
24
|
+
'seo-logs', 'seo-maps', 'seo-migration', 'seo-page', 'seo-plan',
|
|
25
|
+
'seo-programmatic', 'seo-prompt-research', 'seo-reddit-scraper',
|
|
26
|
+
'seo-schema', 'seo-sitemap', 'seo-social-zalo', 'seo-source-context',
|
|
27
|
+
'seo-technical', 'seo-test', 'seo-topical-authority', 'seo-video',
|
|
28
|
+
'seo-video-transcript', 'seo-visual-search',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const SEO_WORKFLOW_FILES = [
|
|
32
|
+
'seo-audit.md', 'seo-auto-run.md', 'seo-execute.md',
|
|
33
|
+
'seo-llm-visibility.md', 'seo-local-suite.md', 'seo-monitor.md',
|
|
34
|
+
'seo-onboard.md', 'seo-page.md', 'seo-research.md', 'seo-run.md',
|
|
35
|
+
'seo-social-commerce.md', 'seo-strategy.md',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const SEO_RULE_FILES = [
|
|
39
|
+
'seo-agent-security.md', 'seo-anti-hallucination.md',
|
|
40
|
+
'seo-auto-routing.md', 'seo-domain-project.md',
|
|
41
|
+
'seo-output-convention.md', 'seo-provider-priority.md',
|
|
42
|
+
'seo-report-format.md',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const SEO_AGENT_FILES = [
|
|
46
|
+
'seo-auditor.md', 'seo-strategist.md', 'seo-content-writer.md',
|
|
47
|
+
'seo-ai-specialist.md', 'seo-local-expert.md', 'seo-growth-hacker.md',
|
|
48
|
+
'seo-data-analyst.md', 'seo-migration-expert.md',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// ─── Install Command ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async function install(licenseKey, cwd) {
|
|
54
|
+
const deviceId = generateFingerprint();
|
|
55
|
+
const deviceName = getDeviceName();
|
|
56
|
+
|
|
57
|
+
// Step 1: Verify license
|
|
58
|
+
const spin = spinner('Verifying license key...').start();
|
|
59
|
+
let verifyResult;
|
|
60
|
+
try {
|
|
61
|
+
verifyResult = await api.verifyLicense(licenseKey, deviceId, deviceName);
|
|
62
|
+
spin.succeed('License verified!');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
spin.fail('License verification failed');
|
|
65
|
+
const errCode = err.responseData?.error?.code || '';
|
|
66
|
+
if (errCode.includes('DeviceLimitReached') || err.message.includes('Device limit')) {
|
|
67
|
+
console.log('');
|
|
68
|
+
error(`Device limit reached. You have ${err.responseData?.error?.data?.current || '?'}/${err.responseData?.error?.data?.max || 3} devices activated.`);
|
|
69
|
+
console.log('');
|
|
70
|
+
info('Run the following to manage your devices:');
|
|
71
|
+
console.log(` ${colorize('gray', 'npx antigravity-seo-kit devices')}`);
|
|
72
|
+
console.log(` ${colorize('gray', 'npx antigravity-seo-kit devices remove <deviceId>')}`);
|
|
73
|
+
} else if (err.isNotFound || errCode.includes('LicenseNotFound')) {
|
|
74
|
+
error('Invalid license key. Please check your key and try again.');
|
|
75
|
+
console.log(` Purchase: ${colorize('cyan', 'https://antigravitykit.solann.io/')}`);
|
|
76
|
+
} else if (errCode.includes('LicenseExpired')) {
|
|
77
|
+
error('License has expired. Please renew your license.');
|
|
78
|
+
console.log(` Renew: ${colorize('cyan', 'https://antigravitykit.solann.io/')}`);
|
|
79
|
+
} else if (errCode.includes('LicenseSuspended') || errCode.includes('LicenseRevoked')) {
|
|
80
|
+
error('License has been suspended or revoked. Contact support.');
|
|
81
|
+
} else {
|
|
82
|
+
error(err.message);
|
|
83
|
+
}
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Step 2: Download assets from server
|
|
88
|
+
console.log('');
|
|
89
|
+
const spinDl = spinner('Downloading SEO Kit assets...').start();
|
|
90
|
+
let downloadResult;
|
|
91
|
+
try {
|
|
92
|
+
const downloadUrl = api.getAssetsDownloadUrl(PACKAGE_VERSION);
|
|
93
|
+
downloadResult = await downloadAndExtract(downloadUrl, licenseKey, cwd);
|
|
94
|
+
spinDl.succeed(`Downloaded and installed ${downloadResult.count} files`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
spinDl.fail('Asset download failed');
|
|
97
|
+
error(err.message);
|
|
98
|
+
info('If this persists, contact support@solann.io');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Step 3: Save license info + manifest
|
|
103
|
+
writeLicenseFile(cwd, {
|
|
104
|
+
key: licenseKey,
|
|
105
|
+
deviceId,
|
|
106
|
+
deviceName,
|
|
107
|
+
version: PACKAGE_VERSION,
|
|
108
|
+
installedAt: new Date().toISOString(),
|
|
109
|
+
plan: verifyResult?.license?.planName || verifyResult?.license?.plan || 'unknown',
|
|
110
|
+
installedFiles: downloadResult.files,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Done!
|
|
114
|
+
console.log('');
|
|
115
|
+
console.log(colorize('green', '═══════════════════════════════════════════════════════'));
|
|
116
|
+
success(`SEO Kit v${PACKAGE_VERSION} installed successfully! (${downloadResult.count} files)`);
|
|
117
|
+
console.log(colorize('green', '═══════════════════════════════════════════════════════'));
|
|
118
|
+
console.log('');
|
|
119
|
+
|
|
120
|
+
info('Next steps:');
|
|
121
|
+
console.log(` ${colorize('yellow', '1.')} Open this folder in ${colorize('bold', 'Google Antigravity')}`);
|
|
122
|
+
console.log(` ${colorize('yellow', '2.')} Use commands: ${colorize('cyan', '/seo-audit')}, ${colorize('cyan', '/seo-page')}, ${colorize('cyan', '/seo-technical')}, etc.`);
|
|
123
|
+
console.log('');
|
|
124
|
+
|
|
125
|
+
if (verifyResult?.devicesUsed != null) {
|
|
126
|
+
info(`Devices: ${verifyResult.devicesUsed}/${verifyResult.maxDevices} activated`);
|
|
127
|
+
}
|
|
128
|
+
console.log('');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Update Command ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async function update(cwd) {
|
|
134
|
+
const config = readLicenseFile(cwd);
|
|
135
|
+
if (!config) {
|
|
136
|
+
error('SEO Kit is not installed in this directory.');
|
|
137
|
+
info('Run: npx antigravity-seo-kit install --key=YOUR_KEY');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const deviceId = generateFingerprint();
|
|
142
|
+
const deviceName = getDeviceName();
|
|
143
|
+
|
|
144
|
+
// Re-verify license
|
|
145
|
+
const spin = spinner('Verifying license...').start();
|
|
146
|
+
try {
|
|
147
|
+
await api.verifyLicense(config.key, deviceId, deviceName);
|
|
148
|
+
spin.succeed('License valid');
|
|
149
|
+
} catch (err) {
|
|
150
|
+
spin.fail('License verification failed');
|
|
151
|
+
error(err.message);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Download latest assets from server
|
|
156
|
+
console.log('');
|
|
157
|
+
const spinDl = spinner('Downloading latest SEO Kit assets...').start();
|
|
158
|
+
let downloadResult;
|
|
159
|
+
try {
|
|
160
|
+
const downloadUrl = api.getAssetsDownloadUrl(PACKAGE_VERSION);
|
|
161
|
+
downloadResult = await downloadAndExtract(downloadUrl, config.key, cwd);
|
|
162
|
+
spinDl.succeed(`Updated ${downloadResult.count} files`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
spinDl.fail('Update download failed');
|
|
165
|
+
error(err.message);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Update license file
|
|
170
|
+
config.version = PACKAGE_VERSION;
|
|
171
|
+
config.updatedAt = new Date().toISOString();
|
|
172
|
+
config.installedFiles = downloadResult.files;
|
|
173
|
+
writeLicenseFile(cwd, config);
|
|
174
|
+
|
|
175
|
+
console.log('');
|
|
176
|
+
success(`SEO Kit updated to v${PACKAGE_VERSION}! (${downloadResult.count} files)`);
|
|
177
|
+
console.log('');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Uninstall Command ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async function uninstall(cwd) {
|
|
183
|
+
const config = readLicenseFile(cwd);
|
|
184
|
+
if (!config) {
|
|
185
|
+
error('SEO Kit is not installed in this directory.');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
step('Removing SEO Kit files...');
|
|
190
|
+
let totalRemoved = 0;
|
|
191
|
+
|
|
192
|
+
if (config.installedFiles && config.installedFiles.length > 0) {
|
|
193
|
+
// New approach: use manifest from license file
|
|
194
|
+
for (const filePath of config.installedFiles) {
|
|
195
|
+
const target = path.join(cwd, filePath);
|
|
196
|
+
if (fs.existsSync(target)) {
|
|
197
|
+
fs.unlinkSync(target);
|
|
198
|
+
totalRemoved++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Clean up empty directories
|
|
202
|
+
cleanEmptyDirsUp(path.join(cwd, '.agent'));
|
|
203
|
+
} else {
|
|
204
|
+
// Legacy fallback: use hardcoded arrays
|
|
205
|
+
totalRemoved += legacyUninstall(cwd);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Remove license file
|
|
209
|
+
removeLicenseFile(cwd);
|
|
210
|
+
|
|
211
|
+
console.log('');
|
|
212
|
+
success(`SEO Kit removed. (${totalRemoved} files deleted)`);
|
|
213
|
+
console.log('');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Legacy uninstall for installs before v1.0.0 (without manifest).
|
|
218
|
+
*/
|
|
219
|
+
function legacyUninstall(cwd) {
|
|
220
|
+
let totalRemoved = 0;
|
|
221
|
+
|
|
222
|
+
// Remove skills
|
|
223
|
+
const skillsDir = path.join(cwd, '.agent', 'skills');
|
|
224
|
+
for (const skillDir of SEO_SKILL_DIRS) {
|
|
225
|
+
const target = path.join(skillsDir, skillDir);
|
|
226
|
+
totalRemoved += removeRecursive(target);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Remove workflows
|
|
230
|
+
const workflowsDir = path.join(cwd, '.agent', 'workflows');
|
|
231
|
+
for (const wfFile of SEO_WORKFLOW_FILES) {
|
|
232
|
+
const target = path.join(workflowsDir, wfFile);
|
|
233
|
+
if (fs.existsSync(target)) {
|
|
234
|
+
fs.unlinkSync(target);
|
|
235
|
+
totalRemoved++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Remove rules
|
|
240
|
+
const rulesDir = path.join(cwd, '.agent', 'rules');
|
|
241
|
+
for (const ruleFile of SEO_RULE_FILES) {
|
|
242
|
+
const target = path.join(rulesDir, ruleFile);
|
|
243
|
+
if (fs.existsSync(target)) {
|
|
244
|
+
fs.unlinkSync(target);
|
|
245
|
+
totalRemoved++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Remove agents
|
|
250
|
+
const agentsDir = path.join(cwd, '.agent', 'agents');
|
|
251
|
+
for (const agentFile of SEO_AGENT_FILES) {
|
|
252
|
+
const target = path.join(agentsDir, agentFile);
|
|
253
|
+
if (fs.existsSync(target)) {
|
|
254
|
+
fs.unlinkSync(target);
|
|
255
|
+
totalRemoved++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return totalRemoved;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Recursively remove empty directories bottom-up.
|
|
264
|
+
*/
|
|
265
|
+
function cleanEmptyDirsUp(dirPath) {
|
|
266
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) return;
|
|
267
|
+
|
|
268
|
+
const entries = fs.readdirSync(dirPath);
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
const full = path.join(dirPath, entry);
|
|
271
|
+
if (fs.statSync(full).isDirectory()) {
|
|
272
|
+
cleanEmptyDirsUp(full);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Re-check after cleaning children
|
|
277
|
+
if (fs.readdirSync(dirPath).length === 0) {
|
|
278
|
+
fs.rmdirSync(dirPath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── Status Command ─────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
async function status(cwd) {
|
|
285
|
+
const config = readLicenseFile(cwd);
|
|
286
|
+
if (!config) {
|
|
287
|
+
error('SEO Kit is not installed in this directory.');
|
|
288
|
+
info('Run: npx antigravity-seo-kit install --key=YOUR_KEY');
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
console.log(`${colorize('bold', 'SEO Kit Installation Status')}`);
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(` ${colorize('cyan', 'Version:')} ${config.version || 'unknown'}`);
|
|
295
|
+
console.log(` ${colorize('cyan', 'License Key:')} ${maskKey(config.key)}`);
|
|
296
|
+
console.log(` ${colorize('cyan', 'Device ID:')} ${config.deviceId}`);
|
|
297
|
+
console.log(` ${colorize('cyan', 'Device Name:')} ${config.deviceName}`);
|
|
298
|
+
console.log(` ${colorize('cyan', 'Installed:')} ${config.installedAt || 'unknown'}`);
|
|
299
|
+
console.log(` ${colorize('cyan', 'Plan:')} ${config.plan || 'unknown'}`);
|
|
300
|
+
console.log('');
|
|
301
|
+
|
|
302
|
+
// Count installed files
|
|
303
|
+
if (config.installedFiles && config.installedFiles.length > 0) {
|
|
304
|
+
// New: count from manifest
|
|
305
|
+
let existingFiles = 0;
|
|
306
|
+
for (const f of config.installedFiles) {
|
|
307
|
+
if (fs.existsSync(path.join(cwd, f))) existingFiles++;
|
|
308
|
+
}
|
|
309
|
+
console.log(` ${colorize('cyan', 'Files:')} ${existingFiles}/${config.installedFiles.length} present`);
|
|
310
|
+
} else {
|
|
311
|
+
// Legacy: check from hardcoded arrays
|
|
312
|
+
const skillsDir = path.join(cwd, '.agent', 'skills');
|
|
313
|
+
let installedSkills = 0;
|
|
314
|
+
for (const s of SEO_SKILL_DIRS) {
|
|
315
|
+
if (fs.existsSync(path.join(skillsDir, s))) installedSkills++;
|
|
316
|
+
}
|
|
317
|
+
console.log(` ${colorize('cyan', 'Skills:')} ${installedSkills}/${SEO_SKILL_DIRS.length} installed`);
|
|
318
|
+
|
|
319
|
+
const workflowsDir = path.join(cwd, '.agent', 'workflows');
|
|
320
|
+
let installedWorkflows = 0;
|
|
321
|
+
for (const w of SEO_WORKFLOW_FILES) {
|
|
322
|
+
if (fs.existsSync(path.join(workflowsDir, w))) installedWorkflows++;
|
|
323
|
+
}
|
|
324
|
+
console.log(` ${colorize('cyan', 'Workflows:')} ${installedWorkflows}/${SEO_WORKFLOW_FILES.length} installed`);
|
|
325
|
+
}
|
|
326
|
+
console.log('');
|
|
327
|
+
|
|
328
|
+
// Server status
|
|
329
|
+
try {
|
|
330
|
+
const spin = spinner('Fetching license status from server...').start();
|
|
331
|
+
const serverStatus = await api.getLicenseStatus(config.key);
|
|
332
|
+
spin.succeed('Server status fetched');
|
|
333
|
+
console.log('');
|
|
334
|
+
console.log(` ${colorize('cyan', 'Server Status:')} ${colorize('green', serverStatus.status || 'active')}`);
|
|
335
|
+
if (serverStatus.expiresAt) {
|
|
336
|
+
console.log(` ${colorize('cyan', 'Expires:')} ${serverStatus.expiresAt}`);
|
|
337
|
+
}
|
|
338
|
+
if (serverStatus.devicesUsed != null) {
|
|
339
|
+
console.log(` ${colorize('cyan', 'Devices:')} ${serverStatus.devicesUsed}/${serverStatus.maxDevices}`);
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
warn('Could not reach license server (offline mode).');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
console.log('');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── Devices Command ────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
async function devices(cwd) {
|
|
351
|
+
const config = readLicenseFile(cwd);
|
|
352
|
+
if (!config) {
|
|
353
|
+
error('SEO Kit is not installed in this directory.');
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const spin = spinner('Fetching device list...').start();
|
|
358
|
+
try {
|
|
359
|
+
const result = await api.listDevices(config.key);
|
|
360
|
+
spin.succeed('Device list retrieved');
|
|
361
|
+
console.log('');
|
|
362
|
+
|
|
363
|
+
const deviceList = result.devices || [];
|
|
364
|
+
const maxDevices = result.maxDevices || 3;
|
|
365
|
+
const currentDeviceId = generateFingerprint();
|
|
366
|
+
|
|
367
|
+
console.log(`${colorize('bold', `Activated Devices (${deviceList.length}/${maxDevices})`)}`);
|
|
368
|
+
console.log('');
|
|
369
|
+
|
|
370
|
+
if (deviceList.length === 0) {
|
|
371
|
+
info('No devices activated.');
|
|
372
|
+
} else {
|
|
373
|
+
for (let i = 0; i < deviceList.length; i++) {
|
|
374
|
+
const d = deviceList[i];
|
|
375
|
+
const isCurrent = d.deviceId === currentDeviceId;
|
|
376
|
+
const marker = isCurrent ? colorize('green', ' ← this device') : '';
|
|
377
|
+
console.log(` ${colorize('yellow', `${i + 1}.`)} ${colorize('bold', d.deviceName || 'Unknown')}${marker}`);
|
|
378
|
+
console.log(` ${colorize('gray', 'ID:')} ${d.deviceId}`);
|
|
379
|
+
console.log(` ${colorize('gray', 'Activated:')} ${d.activatedAt || 'unknown'}`);
|
|
380
|
+
console.log(` ${colorize('gray', 'Last seen:')} ${d.lastSeenAt || 'unknown'}`);
|
|
381
|
+
console.log('');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (deviceList.length >= maxDevices) {
|
|
386
|
+
warn(`Device limit reached (${deviceList.length}/${maxDevices}).`);
|
|
387
|
+
info('Remove a device: npx antigravity-seo-kit devices remove <deviceId>');
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
spin.fail('Failed to fetch devices');
|
|
391
|
+
error(err.message);
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
console.log('');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── Remove Device Command ─────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
async function deviceRemove(cwd, deviceId) {
|
|
400
|
+
const config = readLicenseFile(cwd);
|
|
401
|
+
if (!config) {
|
|
402
|
+
error('SEO Kit is not installed in this directory.');
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!deviceId) {
|
|
407
|
+
error('Device ID required.');
|
|
408
|
+
info('Usage: npx antigravity-seo-kit devices remove <deviceId>');
|
|
409
|
+
info('Run "npx antigravity-seo-kit devices" to see device IDs.');
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const currentDeviceId = generateFingerprint();
|
|
414
|
+
if (deviceId === currentDeviceId) {
|
|
415
|
+
warn('You are removing the current device. You will need to re-activate.');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const spin = spinner(`Removing device ${deviceId}...`).start();
|
|
419
|
+
try {
|
|
420
|
+
await api.removeDevice(config.key, deviceId);
|
|
421
|
+
spin.succeed(`Device ${deviceId} removed successfully`);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
spin.fail('Failed to remove device');
|
|
424
|
+
error(err.message);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
console.log('');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function maskKey(key) {
|
|
433
|
+
if (!key || key.length < 8) return '****';
|
|
434
|
+
return key.substring(0, 7) + '*'.repeat(key.length - 11) + key.substring(key.length - 4);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
module.exports = {
|
|
440
|
+
install,
|
|
441
|
+
update,
|
|
442
|
+
uninstall,
|
|
443
|
+
status,
|
|
444
|
+
devices,
|
|
445
|
+
deviceRemove,
|
|
446
|
+
};
|