@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 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
+ }