tensorgrid-ui 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/lib/index.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Host-половина пакета Obsidian / Ion.
3
+ *
4
+ * Две задачи. Первая — быть строкой композиции: именно через неё
5
+ * `@deepseek-ai/dsh-client-modules` обходит дерево плагинов, находит секцию
6
+ * `dsh.client` в package.json и отдаёт браузеру `client.js`.
7
+ *
8
+ * Вторая — подменить иконку. Внутри страницы это не решается: и вкладка,
9
+ * и PWA-манифест ссылаются на один адрес `/favicon.svg`, а манифест —
10
+ * отдельный статический файл, до которого правкой HTML не добраться. Зато
11
+ * контракт веб-сервера прямо говорит, что статику фронтенда обслуживает
12
+ * fallback-место (`registerFallback`), а именованные маршруты матчатся
13
+ * раньше него. Поэтому один точный маршрут перехватывает обоих
14
+ * потребителей сразу.
15
+ *
16
+ * Импортов здесь намеренно нет: строка композиции загружается раньше, чем
17
+ * о пакете что-либо известно, и неразрешимый импорт уронил бы весь ряд.
18
+ */
19
+
20
+ /**
21
+ * Знак TG. Контуры продублированы из `lib/client.js` осознанно: половины не
22
+ * делят модули, а host обязан оставаться без импортов. Совпадение контуров
23
+ * между половинами проверяет дымовой тест.
24
+ */
25
+ const BRAND_VIEWBOX = '245 230 1047 566'
26
+ const BRAND_PATHS = [
27
+ 'M315 278H929L766 404H626Q620 404 620 410V724C620 737 610 748 597 748H516C503 748 493 738 493 725V411Q493 404 486 404H317C303 404 293 394 293 380V302C293 288 303 278 315 278Z',
28
+ 'M968 278H1126C1190 278 1244 333 1244 402V421Q1244 435 1230 435H1128Q1117 435 1117 424Q1117 404 1099 404H889C867 404 849 422 849 444V604C849 625 866 638 887 638H1086C1103 638 1115 627 1115 611V572Q1115 564 1107 564H954C943 564 935 556 935 546V493C935 482 943 474 954 474H1225C1236 474 1244 483 1244 494V619C1244 691 1192 748 1121 748H847C778 748 724 696 724 625V510C724 475 733 455 755 438L968 278Z',
29
+ ]
30
+
31
+ /**
32
+ * Иконка вкладки. Цвет переключает медиазапрос внутри самого SVG — тем же
33
+ * приёмом, что и поставочная иконка. Тему приложения фавиконка видеть не
34
+ * может, только схему ОС, поэтому здесь это единственный доступный способ.
35
+ */
36
+ const FAVICON =
37
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${BRAND_VIEWBOX}" width="1047" height="566" role="img" aria-label="TENSOR GRID">` +
38
+ '<style>path{fill:#0B0D12}@media (prefers-color-scheme:dark){path{fill:#FFFFFF}}</style>' +
39
+ BRAND_PATHS.map((d) => `<path d="${d}"/>`).join('') +
40
+ '</svg>'
41
+
42
+ export const name = 'tensorgrid-ui'
43
+
44
+ /**
45
+ * Занимает точный маршрут иконки, когда веб-сервер вообще есть. Через
46
+ * `ctx.inject` — чтобы пакет спокойно монтировался и в профиле без
47
+ * браузера: там эта часть просто не активируется.
48
+ * @param ctx - Host-контекст композиции.
49
+ */
50
+ export function apply(ctx) {
51
+ ctx.inject(['webServer'], (web) => {
52
+ web.effect(() =>
53
+ web.webServer.register({
54
+ kind: 'exact',
55
+ path: '/favicon.svg',
56
+ handler(_req, res) {
57
+ res.writeHead(200, {
58
+ 'content-type': 'image/svg+xml; charset=utf-8',
59
+ 'cache-control': 'no-cache',
60
+ })
61
+ res.end(FAVICON)
62
+ },
63
+ }),
64
+ )
65
+
66
+ // Состояние обновлений держим в памяти: проверка ходит в сеть и в git,
67
+ // и дёргать её на каждый рендер настроек незачем.
68
+ let cached = null
69
+
70
+ // Модуль проверки грузится с ключом по времени правки файла.
71
+ //
72
+ // Без этого обновление не может обновить само себя: ESM кеширует
73
+ // модуль по адресу, и после замены файла Host продолжал бы работать по
74
+ // старой логике до перезапуска. Ключ меняется только когда файл
75
+ // действительно изменился, поэтому лишних копий в памяти не остаётся.
76
+ async function loadModule() {
77
+ const path = new URL('./update-check.js', import.meta.url)
78
+ let version = ''
79
+ try {
80
+ const { statSync } = await import('node:fs')
81
+ version = `?v=${statSync(path).mtimeMs}`
82
+ } catch {
83
+ version = ''
84
+ }
85
+ return import(`${path.href}${version}`)
86
+ }
87
+
88
+ async function refresh() {
89
+ const module = await loadModule()
90
+ cached = await module.collectStatus()
91
+ return cached
92
+ }
93
+
94
+ // Проверка при старте — отложенная. Поднимать приложение она не
95
+ // задерживает, а к моменту, когда пользователь откроет настройки,
96
+ // ответ уже готов.
97
+ const timer = setTimeout(() => {
98
+ refresh().catch((error) => {
99
+ cached = { problem: `проверка не удалась: ${String(error?.message ?? error)}`, checkedAt: new Date().toISOString() }
100
+ })
101
+ }, 8000)
102
+ web.effect(() => () => clearTimeout(timer))
103
+
104
+ function reply(res, code, value) {
105
+ const body = JSON.stringify(value)
106
+ res.writeHead(code, {
107
+ 'content-type': 'application/json; charset=utf-8',
108
+ 'cache-control': 'no-store',
109
+ })
110
+ res.end(body)
111
+ }
112
+
113
+ web.effect(() =>
114
+ web.webServer.register({
115
+ kind: 'exact',
116
+ path: '/api/tensorgrid.update',
117
+ async handler(req, res) {
118
+ try {
119
+ if (req.method === 'GET') {
120
+ // force=1 обходит кеш: кнопка «проверить» должна спрашивать заново.
121
+ const fresh = String(req.url ?? '').includes('force=1')
122
+ reply(res, 200, fresh || cached === null ? await refresh() : cached)
123
+ return
124
+ }
125
+
126
+ if (req.method === 'POST') {
127
+ // Запись требует JSON-заголовка: браузер не отправит такой
128
+ // запрос с чужой страницы без предварительной проверки CORS,
129
+ // а она на этот маршрут не настроена. Для инструмента,
130
+ // слушающего только петлевой интерфейс, этого достаточно.
131
+ const contentType = String(req.headers['content-type'] ?? '')
132
+ if (!contentType.includes('application/json')) {
133
+ reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
134
+ return
135
+ }
136
+ const module = await loadModule()
137
+ const result = await module.applyUpdate()
138
+ cached = result.status ?? cached
139
+ reply(res, result.ok ? 200 : 409, result)
140
+ return
141
+ }
142
+
143
+ reply(res, 405, { ok: false, problem: 'поддерживаются GET и POST' })
144
+ } catch (error) {
145
+ reply(res, 500, { ok: false, problem: String(error?.message ?? error) })
146
+ }
147
+ },
148
+ }),
149
+ )
150
+ })
151
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Проверка и применение обновлений — Host-сторона.
3
+ *
4
+ * Обновления приходят с двух сторон: от разработчика (новый коммит) и от
5
+ * DeepSeek (новая версия dsh в npm). Каналы независимы, и опасен второй без
6
+ * первого: пакет опирается на контракты dsh, и если их никто не проверил,
7
+ * поломка обнаружится у пользователя.
8
+ *
9
+ * Поэтому здесь НЕ предлагается обновить dsh самостоятельно. Показывается
10
+ * только то, что выпустил разработчик, — а он выпускает версию уже с
11
+ * отметкой, на какой версии dsh она проверена.
12
+ *
13
+ * Импортируются только встроенные модули Node: строка композиции грузится
14
+ * раньше, чем о пакете что-либо известно, и неразрешимый импорт уронил бы
15
+ * весь ряд.
16
+ */
17
+ import { execFile } from 'node:child_process'
18
+ import { readFile } from 'node:fs/promises'
19
+ import { existsSync } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ /** Запуск команды с жёстким таймаутом: висящий git не должен держать проверку. */
24
+ function run(command, args, cwd, timeout = 20000) {
25
+ return new Promise((resolve) => {
26
+ execFile(command, args, { cwd, timeout, windowsHide: true, shell: false }, (error, stdout, stderr) => {
27
+ resolve({
28
+ ok: !error,
29
+ stdout: String(stdout ?? '').trim(),
30
+ stderr: String(stderr ?? '').trim(),
31
+ message: error === null || error === undefined ? null : String(error.message ?? error),
32
+ })
33
+ })
34
+ })
35
+ }
36
+
37
+ /**
38
+ * Каталог профиля: отсюда `lib/index.js` лежит в
39
+ * `<профиль>/packages/<пакет>/lib/`. Поднимаемся, пока не найдём отметку об
40
+ * установке — так путь не зависит от того, как назван пакет.
41
+ */
42
+ function findProfileRoot(startDir) {
43
+ let dir = startDir
44
+ for (let depth = 0; depth < 6; depth += 1) {
45
+ if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
46
+ const parent = dirname(dir)
47
+ if (parent === dir) break
48
+ dir = parent
49
+ }
50
+ return null
51
+ }
52
+
53
+ async function readStamp(profileRoot) {
54
+ if (profileRoot === null) return null
55
+ try {
56
+ const text = await readFile(join(profileRoot, 'tensorgrid-installed.json'), 'utf8')
57
+ // BOM срезается намеренно: файл может быть записан инструментом
58
+ // Windows, а JSON.parse на строке с \uFEFF падает — и проверка
59
+ // обновлений молча решила бы, что установки не было.
60
+ return JSON.parse(text.replace(/^\uFEFF/, ''))
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Состояние обновлений. Ничего не меняет.
68
+ * @returns сведения об установленной и доступной версиях плюс причина, если проверить не удалось.
69
+ */
70
+ export async function collectStatus() {
71
+ const here = dirname(fileURLToPath(import.meta.url))
72
+ const profileRoot = findProfileRoot(here)
73
+ const stamp = await readStamp(profileRoot)
74
+
75
+ const status = {
76
+ installedCommit: stamp?.commit ?? null,
77
+ installedAt: stamp?.installedAt ?? null,
78
+ dshExpected: stamp?.dshPinned ?? null,
79
+ dshActual: stamp?.dshActual ?? null,
80
+ source: stamp?.source ?? null,
81
+ available: null,
82
+ behind: 0,
83
+ updateAvailable: false,
84
+ canUpdate: false,
85
+ problem: null,
86
+ checkedAt: new Date().toISOString(),
87
+ }
88
+
89
+ if (stamp === null) {
90
+ status.problem = 'нет отметки об установке — запустите install.ps1'
91
+ return status
92
+ }
93
+ if (status.source === null || !existsSync(status.source)) {
94
+ status.problem = 'каталог с исходниками недоступен; обновление через интерфейс невозможно'
95
+ return status
96
+ }
97
+
98
+ // git fetch трогает сеть, но рабочее дерево не меняет: посмотреть, что
99
+ // выпущено, можно не забирая изменений.
100
+ const fetched = await run('git', ['fetch', '--quiet', 'origin'], status.source, 30000)
101
+ if (!fetched.ok) {
102
+ status.problem = `не удалось связаться с репозиторием: ${fetched.stderr || fetched.message || 'неизвестно'}`
103
+ return status
104
+ }
105
+
106
+ const head = await run('git', ['rev-parse', '--short', 'HEAD'], status.source)
107
+ const remote = await run('git', ['rev-parse', '--short', 'origin/HEAD'], status.source)
108
+ const remoteRef = remote.ok ? 'origin/HEAD' : 'origin/main'
109
+ const remoteShort = remote.ok ? remote.stdout : (await run('git', ['rev-parse', '--short', remoteRef], status.source)).stdout
110
+
111
+ status.available = remoteShort || null
112
+
113
+ // Сравнивается УСТАНОВЛЕННОЕ с выпущенным, а не рабочая копия с сервером.
114
+ //
115
+ // Разница принципиальна на машине разработчика: он коммитит в ту же
116
+ // рабочую копию, поэтому она всегда совпадает с сервером, а установленный
117
+ // продукт при этом может отставать на десяток коммитов. Но и у сотрудника
118
+ // это важно: `git pull` без запуска установщика обновляет исходники, не
119
+ // трогая то, что реально работает.
120
+ const installed = status.installedCommit
121
+ if (installed === null || installed === 'неизвестно') {
122
+ status.problem = 'не удалось определить установленную версию — переустановите'
123
+ return status
124
+ }
125
+
126
+ if (installed === status.available) {
127
+ status.behind = 0
128
+ status.updateAvailable = false
129
+ } else {
130
+ // Разные хеши — ещё не повод обновляться. Установленное может
131
+ // ОПЕРЕЖАТЬ выпущенное: так выглядит машина разработчика, который уже
132
+ // поставил себе то, что ещё не запушил. Решает направление, а не факт
133
+ // расхождения.
134
+ const behind = await run('git', ['rev-list', '--count', `${installed}..${remoteRef}`], status.source)
135
+ const ahead = await run('git', ['rev-list', '--count', `${remoteRef}..${installed}`], status.source)
136
+
137
+ if (!behind.ok) {
138
+ // Установленного коммита нет в истории — например, ветку переписали.
139
+ // Сравнить нечем, но молчать нельзя.
140
+ status.behind = 0
141
+ status.updateAvailable = true
142
+ status.unknownHistory = true
143
+ } else {
144
+ status.behind = Number.parseInt(behind.stdout, 10) || 0
145
+ status.ahead = ahead.ok ? Number.parseInt(ahead.stdout, 10) || 0 : 0
146
+ status.updateAvailable = status.behind > 0
147
+ }
148
+ }
149
+
150
+ // Обновлять можно только из чистого дерева: иначе git pull упрётся в
151
+ // локальные правки, а разбираться с этим из интерфейса нечем.
152
+ const dirty = await run('git', ['status', '--porcelain'], status.source)
153
+ const isDirty = dirty.ok && dirty.stdout.length > 0
154
+ status.canUpdate = status.updateAvailable && !isDirty && head.ok
155
+ if (status.updateAvailable && isDirty) {
156
+ status.problem = 'в каталоге с исходниками есть несохранённые правки — обновление через интерфейс отключено'
157
+ }
158
+
159
+ return status
160
+ }
161
+
162
+ /**
163
+ * Забирает новую версию и переустанавливает. Возвращает отчёт и признак
164
+ * того, достаточно ли перезагрузки страницы.
165
+ */
166
+ export async function applyUpdate() {
167
+ const status = await collectStatus()
168
+ if (!status.canUpdate) {
169
+ return { ok: false, problem: status.problem ?? 'обновлять нечего', status }
170
+ }
171
+
172
+ const before = await run('git', ['rev-parse', 'HEAD'], status.source)
173
+ const pulled = await run('git', ['pull', '--ff-only'], status.source, 60000)
174
+ if (!pulled.ok) {
175
+ return { ok: false, problem: `git pull не удался: ${pulled.stderr || pulled.message}`, status }
176
+ }
177
+ const after = await run('git', ['rev-parse', 'HEAD'], status.source)
178
+
179
+ // Что именно изменилось, решает, хватит ли перезагрузки страницы.
180
+ // Браузерный бандл читается при загрузке страницы, а host-половина,
181
+ // патч-слой и пресет — только при старте приложения.
182
+ const changed = await run('git', ['diff', '--name-only', before.stdout, after.stdout], status.source)
183
+ const files = changed.ok ? changed.stdout.split('\n').map((line) => line.trim()).filter(Boolean) : []
184
+ const needsRestart = files.some((file) =>
185
+ file === 'lib/index.js' || file.startsWith('profile/') || file.startsWith('preset/') || file === 'install.ps1',
186
+ )
187
+
188
+ const installer = join(status.source, 'install.ps1')
189
+ if (!existsSync(installer)) {
190
+ return { ok: false, problem: `не найден установщик: ${installer}`, status }
191
+ }
192
+
193
+ const installed = await run(
194
+ 'powershell',
195
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', installer, '-NoAutostart'],
196
+ status.source,
197
+ 180000,
198
+ )
199
+ if (!installed.ok) {
200
+ return {
201
+ ok: false,
202
+ problem: 'установщик завершился с ошибкой',
203
+ detail: (installed.stdout || installed.stderr || '').split('\n').slice(-12).join('\n'),
204
+ status,
205
+ }
206
+ }
207
+
208
+ return {
209
+ ok: true,
210
+ from: before.stdout.slice(0, 7),
211
+ to: after.stdout.slice(0, 7),
212
+ files: files.length,
213
+ needsRestart,
214
+ status: await collectStatus(),
215
+ }
216
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "tensorgrid-ui",
3
+ "version": "1.0.0",
4
+ "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib"
18
+ ],
19
+ "publishConfig": {
20
+ "registry": "https://registry.npmjs.org"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/tayyarg07/tensorgrid-ui.git"
25
+ },
26
+ "dsh": {
27
+ "client": {
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-ui-renderer",
30
+ "@deepseek-ai/dsh-client-ui-theme"
31
+ ],
32
+ "platform": "web"
33
+ }
34
+ },
35
+ "license": "SEE LICENSE IN LICENSE"
36
+ }