@tuqo/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/README.md +38 -0
- package/bin/tuqo.js +78 -0
- package/lib/deploy.js +93 -0
- package/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @tuqo/cli
|
|
2
|
+
|
|
3
|
+
Деплой статических сайтов на [Tuqo](https://tuqo.ru) — content-addressed, с дедупом.
|
|
4
|
+
Файлы грузятся поштучно по sha256: нет лимита на размер тела запроса, а совпадающие
|
|
5
|
+
файлы между деплоями **не перезаливаются** (правка текста не трогает картинки).
|
|
6
|
+
|
|
7
|
+
## Использование
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
TUQO_KEY=tqk_xxx_yyy npx @tuqo/cli deploy ./dist --site <site_id> --wait
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Опция | |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `--site <id>` | site_id (из панели, `whoami` или `create_site`) — обязателен |
|
|
16
|
+
| `--key <tqk_>` | project API key (или env `TUQO_KEY`) |
|
|
17
|
+
| `--api <url>` | базовый адрес API (по умолчанию `https://api.tuqo.ru`) |
|
|
18
|
+
| `--wait` | дождаться окончания сборки (poll статуса) |
|
|
19
|
+
|
|
20
|
+
В каталоге должен быть `index.html` в корне. Лимиты: до 2000 файлов, до 200 МБ суммарно,
|
|
21
|
+
до 30 МБ на файл. Для агентов (Claude Code и т.п.): байты читаются с диска и не проходят
|
|
22
|
+
через контекст модели — медиа-сайты деплоятся без расхода токенов на base64.
|
|
23
|
+
|
|
24
|
+
CLI — тонкая обёртка над публичным REST-API манифест-деплоя
|
|
25
|
+
(`/deploys/check` → `PUT /blobs/{sha256}` → `/deploys/manifest`), документация: https://tuqo.ru/api#uploads
|
|
26
|
+
|
|
27
|
+
## Публикация в npm (для мейнтейнеров)
|
|
28
|
+
|
|
29
|
+
Пакет zero-dependency и готов к публикации. Нужен доступ к npm-организации `@tuqo`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd cli
|
|
33
|
+
npm login # под аккаунтом с доступом к @tuqo
|
|
34
|
+
npm publish # publishConfig.access=public уже задан
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
До публикации можно гонять локально: `node cli/bin/tuqo.js deploy ./dist --site <id>`.
|
|
38
|
+
|
package/bin/tuqo.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
// Tuqo CLI. Использование:
|
|
4
|
+
// npx @tuqo/cli deploy <dir> --site <site_id> [--key tqk_...] [--api https://api.tuqo.ru] [--wait]
|
|
5
|
+
// Ключ можно задать через env TUQO_KEY. site_id — из панели или whoami/create_site.
|
|
6
|
+
|
|
7
|
+
const { deployDir, api } = require('../lib/deploy')
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const a = { _: [] }
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const t = argv[i]
|
|
13
|
+
if (t.startsWith('--')) {
|
|
14
|
+
const k = t.slice(2)
|
|
15
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) a[k] = argv[++i]
|
|
16
|
+
else a[k] = true
|
|
17
|
+
} else a._.push(t)
|
|
18
|
+
}
|
|
19
|
+
return a
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HELP = `Tuqo CLI — деплой статических сайтов (content-addressed, дедуп).
|
|
23
|
+
|
|
24
|
+
npx @tuqo/cli deploy <dir> --site <site_id> [опции]
|
|
25
|
+
|
|
26
|
+
Опции:
|
|
27
|
+
--site <id> site_id (обязателен)
|
|
28
|
+
--key <tqk_> project API key (или env TUQO_KEY)
|
|
29
|
+
--api <url> базовый адрес API (по умолчанию https://api.tuqo.ru)
|
|
30
|
+
--wait дождаться завершения сборки (poll статуса)
|
|
31
|
+
|
|
32
|
+
Примеры:
|
|
33
|
+
TUQO_KEY=tqk_xxx_yyy npx @tuqo/cli deploy ./dist --site 1234... --wait
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
async function pollStatus(apiBase, key, deployId, log) {
|
|
37
|
+
for (let i = 0; i < 120; i++) {
|
|
38
|
+
const d = await api(apiBase, key, 'GET', `/api/v1/deploys/${deployId}`)
|
|
39
|
+
if (d.status === 'active') { log(`статус: active ✓`); return d }
|
|
40
|
+
if (d.status === 'failed') { throw new Error(`сборка упала: ${d.error || 'см. логи'}`) }
|
|
41
|
+
log(`статус: ${d.status}…`)
|
|
42
|
+
await new Promise((r) => setTimeout(r, d.recommended_poll_ms || 2000))
|
|
43
|
+
}
|
|
44
|
+
throw new Error('таймаут ожидания сборки')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const args = parseArgs(process.argv.slice(2))
|
|
49
|
+
const cmd = args._[0]
|
|
50
|
+
if (!cmd || cmd === 'help' || args.help) { process.stdout.write(HELP); return }
|
|
51
|
+
|
|
52
|
+
if (cmd === 'deploy') {
|
|
53
|
+
const dir = args._[1]
|
|
54
|
+
const siteId = args.site
|
|
55
|
+
const key = args.key || process.env.TUQO_KEY
|
|
56
|
+
const apiBase = (args.api || 'https://api.tuqo.ru').replace(/\/$/, '')
|
|
57
|
+
if (!dir) throw new Error('укажите каталог: deploy <dir>')
|
|
58
|
+
if (!siteId) throw new Error('укажите --site <site_id>')
|
|
59
|
+
if (!key) throw new Error('укажите --key tqk_... или env TUQO_KEY')
|
|
60
|
+
|
|
61
|
+
const log = (m) => process.stderr.write(m + '\n')
|
|
62
|
+
log(`деплой ${dir} → сайт ${siteId}`)
|
|
63
|
+
const deploy = await deployDir(dir, { apiBase, key, siteId, log })
|
|
64
|
+
log(`деплой создан: ${deploy.id} (${deploy.status})`)
|
|
65
|
+
if (args.wait) await pollStatus(apiBase, key, deploy.id, log)
|
|
66
|
+
// В stdout — машиночитаемый id (для скриптов/агентов).
|
|
67
|
+
process.stdout.write(deploy.id + '\n')
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(`неизвестная команда: ${cmd} (см. npx @tuqo/cli help)`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch((e) => {
|
|
75
|
+
process.stderr.write(`ошибка: ${e.message}\n`)
|
|
76
|
+
if (e.data && e.data.missing) process.stderr.write(`не загружено: ${e.data.missing.length} блобов\n`)
|
|
77
|
+
process.exit(1)
|
|
78
|
+
})
|
package/lib/deploy.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Манифест-деплой: хешируем файлы → узнаём недостающие блобы → грузим их поштучно
|
|
3
|
+
// → создаём деплой. Байты не проходят через контекст модели (читаются с диска),
|
|
4
|
+
// дедуп не перезаливает совпадающие файлы между деплоями. Zero-dep (Node 18+).
|
|
5
|
+
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
const crypto = require('crypto')
|
|
9
|
+
|
|
10
|
+
/** Рекурсивно собирает файлы каталога в [{ rel, abs }] (POSIX-пути, без симлинков). */
|
|
11
|
+
function walk(dir, base = dir, out = []) {
|
|
12
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
13
|
+
const abs = path.join(dir, entry.name)
|
|
14
|
+
if (entry.isDirectory()) walk(abs, base, out)
|
|
15
|
+
else if (entry.isFile()) {
|
|
16
|
+
const rel = path.relative(base, abs).split(path.sep).join('/')
|
|
17
|
+
out.push({ rel, abs })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sha256File(abs) {
|
|
24
|
+
return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function api(base, key, method, route, { json, body, raw } = {}) {
|
|
28
|
+
const headers = { Authorization: `Bearer ${key}` }
|
|
29
|
+
let payload = body
|
|
30
|
+
if (json !== undefined) {
|
|
31
|
+
headers['Content-Type'] = 'application/json'
|
|
32
|
+
payload = JSON.stringify(json)
|
|
33
|
+
} else if (raw !== undefined) {
|
|
34
|
+
headers['Content-Type'] = 'application/octet-stream'
|
|
35
|
+
payload = raw
|
|
36
|
+
}
|
|
37
|
+
const res = await fetch(`${base}${route}`, { method, headers, body: payload })
|
|
38
|
+
const text = await res.text()
|
|
39
|
+
let data
|
|
40
|
+
try { data = text ? JSON.parse(text) : {} } catch { data = { raw: text } }
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const msg = data && (data.error || data.message) ? data.error || data.message : `HTTP ${res.status}`
|
|
43
|
+
const err = new Error(msg)
|
|
44
|
+
err.status = res.status
|
|
45
|
+
err.data = data
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
return data
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Деплой каталога `dir` на сайт `siteId`.
|
|
53
|
+
* opts: { apiBase, key, siteId, log }
|
|
54
|
+
* Возвращает объект деплоя (как REST `POST /deploys/manifest`).
|
|
55
|
+
*/
|
|
56
|
+
async function deployDir(dir, { apiBase, key, siteId, log = () => {} }) {
|
|
57
|
+
const files = walk(dir)
|
|
58
|
+
if (files.length === 0) throw new Error(`каталог пуст: ${dir}`)
|
|
59
|
+
if (!files.some((f) => f.rel === 'index.html')) {
|
|
60
|
+
throw new Error('нужен index.html в корне каталога')
|
|
61
|
+
}
|
|
62
|
+
// Манифест: путь → sha256 (+ размер).
|
|
63
|
+
const manifest = files.map((f) => {
|
|
64
|
+
const sha256 = sha256File(f.abs)
|
|
65
|
+
return { path: f.rel, sha256, size: fs.statSync(f.abs).size, abs: f.abs }
|
|
66
|
+
})
|
|
67
|
+
log(`файлов: ${manifest.length}, хеширование готово`)
|
|
68
|
+
|
|
69
|
+
// 1) Какие блобы серверу не хватает.
|
|
70
|
+
const wire = manifest.map(({ path, sha256, size }) => ({ path, sha256, size }))
|
|
71
|
+
const { missing } = await api(apiBase, key, 'POST', `/api/v1/sites/${siteId}/deploys/check`, {
|
|
72
|
+
json: { files: wire },
|
|
73
|
+
})
|
|
74
|
+
log(`к загрузке: ${missing.length} (дедуп переиспользовал ${manifest.length - missing.length})`)
|
|
75
|
+
|
|
76
|
+
// 2) Грузим недостающие блобы поштучно (по одному уникальному хешу).
|
|
77
|
+
const need = new Set(missing)
|
|
78
|
+
const uploadedHash = new Set()
|
|
79
|
+
for (const f of manifest) {
|
|
80
|
+
if (!need.has(f.sha256) || uploadedHash.has(f.sha256)) continue
|
|
81
|
+
await api(apiBase, key, 'PUT', `/api/v1/blobs/${f.sha256}`, { raw: fs.readFileSync(f.abs) })
|
|
82
|
+
uploadedHash.add(f.sha256)
|
|
83
|
+
log(` загружен ${f.path} (${f.sha256.slice(0, 12)}…)`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3) Создаём деплой из манифеста.
|
|
87
|
+
const deploy = await api(apiBase, key, 'POST', `/api/v1/sites/${siteId}/deploys/manifest`, {
|
|
88
|
+
json: { files: manifest.map(({ path, sha256 }) => ({ path, sha256 })) },
|
|
89
|
+
})
|
|
90
|
+
return deploy
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { deployDir, api }
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tuqo/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tuqo CLI — деплой статических сайтов (content-addressed, дедуп). npx @tuqo/cli deploy ./dist",
|
|
5
|
+
"bin": {
|
|
6
|
+
"tuqo": "bin/tuqo.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"lib/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": ["tuqo", "deploy", "static", "hosting", "cli"],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://tuqo.ru/docs",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
}
|
|
23
|
+
}
|