local-kanban 1.11.0 → 1.13.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/CHANGELOG.md +56 -0
- package/README.md +6 -0
- package/README.ru.md +6 -0
- package/package.json +1 -1
- package/public/js/core.js +55 -2
- package/public/js/project.js +2 -2
- package/public/js/settings.js +45 -2
- package/public/js/sidebar.js +114 -9
- package/public/style.css +23 -3
- package/src/routes/projects.js +22 -0
- package/src/routes/system.js +52 -13
package/CHANGELOG.md
CHANGED
|
@@ -3,10 +3,66 @@
|
|
|
3
3
|
Notable changes, newest first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
4
4
|
and the project uses [semantic versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
Anything not shipped yet is collected under **Unreleased**; releasing renames that section to the
|
|
7
|
+
version it goes out as and opens an empty one again. Released sections are history and stay as they are.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.13.0]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **The board tells you what's new.** The changelog was written for every release and shown nowhere:
|
|
16
|
+
the only place to read it was the package page on npm. The About screen now has a "What's new"
|
|
17
|
+
block. For the version you are running it reads the `CHANGELOG.md` that ships next to the code, so
|
|
18
|
+
it needs no network at all. When an update is waiting, it shows the notes of the version you do
|
|
19
|
+
*not* have yet, read from that release's page on GitHub — the decision to update is made on what
|
|
20
|
+
actually changed, not on a version number. Releases now get such a page: publishing puts the
|
|
21
|
+
changelog section for that version into it, which also gives the npm page real releases to link to.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **A folder the board is not allowed to read is no longer reported as missing.** The Check button
|
|
26
|
+
next to the project folder answered every refusal the same way — "no such folder on this computer" —
|
|
27
|
+
whether the folder was absent, was a file, or simply could not be read. For a folder without
|
|
28
|
+
permissions that is plainly untrue: it is there, the board was not let in, and the answer sent you
|
|
29
|
+
off to fix a path that was already correct. Each case now says what actually happened, and a check
|
|
30
|
+
that could not reach the server says so instead of blaming the path.
|
|
31
|
+
|
|
32
|
+
## [1.12.0]
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **The wizard answers before you press Create.** Three checks with one result line each: the ssh host
|
|
37
|
+
("Check" runs a real connection, and says *why* it failed — unknown host, refused key, unknown host
|
|
38
|
+
key, no answer — rather than handing you ssh's stderr), the project folder, and the project key,
|
|
39
|
+
whose availability now shows while you type instead of arriving as a refusal after Create. The key
|
|
40
|
+
line also shows the task prefix you are going to get, which until now you first saw on your first
|
|
41
|
+
task and could never change.
|
|
42
|
+
|
|
43
|
+
- **A third way to add a project: let Claude do it.** Half of the form (ssh host, server path, pm2,
|
|
44
|
+
deploy skill) needs you to go and look something up, and Claude is right there and can look. The
|
|
45
|
+
first step now offers a ready prompt to copy: it makes Claude inspect the folder, verify the ssh
|
|
46
|
+
access itself, ask you one question at a time, and register the project when you have both agreed.
|
|
47
|
+
|
|
48
|
+
- **The git clone mode says where the repository lands.** It was called "clone from a git URL…" and
|
|
49
|
+
said nothing else. It now shows the actual destination path on this computer, and the two facts you
|
|
50
|
+
could not have guessed: an existing folder is refused rather than overwritten, and git credentials
|
|
51
|
+
are never asked for here.
|
|
52
|
+
|
|
6
53
|
## [1.11.0]
|
|
7
54
|
|
|
8
55
|
### Fixed
|
|
9
56
|
|
|
57
|
+
- **Where the SSH key or password goes: nowhere, and that is now said out loud.** The fields told
|
|
58
|
+
you access "comes from your `~/.ssh/config`", which answers where it comes from and not what you
|
|
59
|
+
are supposed to do. The board holds no credentials at all: it runs `ssh <host>`, and a password
|
|
60
|
+
cannot work even in principle, because it connects without prompts — key-based access is required.
|
|
61
|
+
The short test is in the hint now: if `ssh <host>` works in your terminal, it works here. The same
|
|
62
|
+
paragraph went into the security section of both READMEs, which said nothing about ssh until now.
|
|
63
|
+
The server fields also got a heading of their own, and the host field shows an address as an
|
|
64
|
+
example rather than a description of one.
|
|
65
|
+
|
|
10
66
|
- **The server fields say which side they are about.** "SSH host" and "Server path" gave no hint of
|
|
11
67
|
whether they meant this computer or the machine you deploy to — a fair question, and the owner of
|
|
12
68
|
the board asked it. A line above the group now answers it, and the ssh field says the part you
|
package/README.md
CHANGED
|
@@ -38,6 +38,12 @@ than what it actually shipped.
|
|
|
38
38
|
|
|
39
39
|
The server listens on **127.0.0.1 only** and has **no authentication**. The board is strictly local: your machine is the trust boundary. **Never expose the port to the network** (no reverse proxies, no `0.0.0.0`, no port forwarding). Third-party integrations should run on the same machine and talk to `127.0.0.1` (see [docs/API.md](docs/API.md)).
|
|
40
40
|
|
|
41
|
+
**The board holds no credentials.** A project can name an SSH host to deploy to, but neither a key
|
|
42
|
+
nor a password is ever entered here: the board simply runs `ssh <host>`, and who you are, on which
|
|
43
|
+
port and with which key is decided by your own `~/.ssh/config` — the same file your terminal uses.
|
|
44
|
+
A password will not work at all, because the board connects without prompts, so key-based access is
|
|
45
|
+
required. The short test: if `ssh <host>` works in your terminal, it works here.
|
|
46
|
+
|
|
41
47
|
## Requirements
|
|
42
48
|
|
|
43
49
|
- **Node.js ≥ 22** (macOS or Linux) — 24 LTS or newer is what most people have
|
package/README.ru.md
CHANGED
|
@@ -38,6 +38,12 @@ English version — [README.md](README.md).
|
|
|
38
38
|
|
|
39
39
|
Сервер слушает **только 127.0.0.1**, авторизации **нет**. Доска строго локальная: граница доверия — твоя машина. **Никогда не выставляй порт наружу** (никаких reverse proxy, `0.0.0.0`, проброса портов). Сторонние интеграции запускаются на той же машине и ходят на `127.0.0.1` (см. [docs/API.md](docs/API.md)).
|
|
40
40
|
|
|
41
|
+
**Доска не хранит доступов.** У проекта можно указать ssh-хост, куда он выкатывается, но ни ключ, ни
|
|
42
|
+
пароль здесь не вводятся: доска просто запускает `ssh <хост>`, а кто ты, на каком порту и с каким
|
|
43
|
+
ключом — решает твой собственный `~/.ssh/config`, тот же файл, которым пользуется терминал. Пароль
|
|
44
|
+
не подойдёт вовсе: доска подключается без запросов, значит нужен ключ. Проверка одной строкой:
|
|
45
|
+
работает `ssh <хост>` в терминале — заработает и здесь.
|
|
46
|
+
|
|
41
47
|
## Требования
|
|
42
48
|
|
|
43
49
|
- **Node.js ≥ 22** (macOS или Linux) — у большинства стоит 24 LTS или новее
|
package/package.json
CHANGED
package/public/js/core.js
CHANGED
|
@@ -375,9 +375,11 @@ const I18N_RU = {
|
|
|
375
375
|
'Claude moves these columns itself': 'эти колонки двигает сам Claude',
|
|
376
376
|
'no file': 'файла нет',
|
|
377
377
|
'no folders inside': 'внутри нет папок',
|
|
378
|
+
'no notes for this version': 'для этой версии заметок нет',
|
|
378
379
|
'no such folder on this computer': 'такой папки на этом компьютере нет',
|
|
379
380
|
'not deleted': 'не удалено',
|
|
380
381
|
'not installed': 'не установлен',
|
|
382
|
+
'not installed yet': 'ещё не установлена',
|
|
381
383
|
'not set': 'не задан',
|
|
382
384
|
'not set — no deploy': 'не задан — деплоя нет',
|
|
383
385
|
'not there': 'нет файла',
|
|
@@ -413,8 +415,32 @@ const I18N_RU = {
|
|
|
413
415
|
'the skill is not installed — the file will be created on save': 'скилл не установлен — файл появится при сохранении',
|
|
414
416
|
'the update did not go through': 'обновиться не удалось',
|
|
415
417
|
'Run it yourself': 'Выполни сам',
|
|
416
|
-
'
|
|
417
|
-
'
|
|
418
|
+
'Server': 'Сервер',
|
|
419
|
+
'Check': 'Проверить',
|
|
420
|
+
'checking…': 'проверяю…',
|
|
421
|
+
'connecting…': 'подключаюсь…',
|
|
422
|
+
'connected': 'подключились',
|
|
423
|
+
'the server path is there': 'путь на сервере есть',
|
|
424
|
+
'but there is no such path on the server': 'но такого пути на сервере нет',
|
|
425
|
+
'give the ssh host': 'укажи ssh-хост',
|
|
426
|
+
'the folder is there': 'папка на месте',
|
|
427
|
+
'subfolders': 'подпапок',
|
|
428
|
+
'key is free': 'ключ свободен',
|
|
429
|
+
'task prefix': 'префикс задач',
|
|
430
|
+
'the folder is there, but the board cannot read it — check its permissions': 'папка есть, но доска не может её прочитать — посмотри права доступа',
|
|
431
|
+
'this key is already taken by': 'этот ключ уже занят проектом',
|
|
432
|
+
'no such host — check the name, or add it to your ~/.ssh/config': 'такого хоста нет — проверь имя или добавь его в свой ~/.ssh/config',
|
|
433
|
+
'the server refused the key — a password will not help here, the key has to be authorized': 'сервер не принял ключ — пароль тут не поможет, ключ надо разрешить на сервере',
|
|
434
|
+
'the host key is unknown — connect once from your terminal and confirm the fingerprint': 'ключ хоста неизвестен — подключись разок из терминала и подтверди отпечаток',
|
|
435
|
+
'the server did not answer — wrong address, wrong port, or it is down': 'сервер не ответил — не тот адрес, не тот порт или он лежит',
|
|
436
|
+
'could not check the folder': 'не удалось проверить папку',
|
|
437
|
+
'could not connect': 'подключиться не вышло',
|
|
438
|
+
'on create, git clone puts it on THIS computer at': 'при создании git clone положит его на ЭТОТ компьютер в',
|
|
439
|
+
'An existing folder is refused, not overwritten. Git credentials are not asked here — access comes from your own git and ssh setup.': 'Если папка уже есть — откажет, а не перезапишет. Логин git тут не спрашивается: доступ берётся из твоих git-настроек и ssh-ключей.',
|
|
440
|
+
'or let Claude do it': 'или пусть настроит Claude',
|
|
441
|
+
'Claude looks at the folder itself, checks the server access, and asks you one question at a time. Paste this into your Claude Code chat.': 'Claude сам посмотрит папку, проверит доступ к серверу и задаст вопросы по одному. Вставь это в чат Claude Code.',
|
|
442
|
+
'Not about this computer. Empty means the project simply stays local.': 'Не про этот компьютер. Пусто — проект просто останется локальным.',
|
|
443
|
+
'no key or password is entered here: access comes from your ~/.ssh/config, and a password will not do — a key is required. If ssh <host> works in your terminal, it works here.': 'ключ и пароль здесь не вводятся: доступ берётся из твоего ~/.ssh/config, и пароль не подойдёт — нужен ключ. Работает ssh <хост> в терминале — заработает и здесь.',
|
|
418
444
|
'“Local folder” is on this computer. The rest describes the server this project is deployed to — leave it empty and the project simply stays local.': '«Папка на компьютере» — это здесь, на твоей машине. Остальное про сервер, на который выкатывается проект: оставь пустым — и проект просто останется локальным.',
|
|
419
445
|
'the board runs ssh <host> — user, port and key come from your ~/.ssh/config, no secrets are kept here': 'доска выполняет ssh <хост> — пользователь, порт и ключ берутся из твоего ~/.ssh/config, секретов доска не хранит',
|
|
420
446
|
'where the project lies ON THE SERVER — the deploy pulls into this folder': 'где проект лежит НА СЕРВЕРЕ — именно в эту папку деплой делает git pull',
|
|
@@ -513,6 +539,7 @@ const I18N_RU = {
|
|
|
513
539
|
'recovered on retry': 'починилось повторной попыткой',
|
|
514
540
|
'related': 'связана',
|
|
515
541
|
'Read': 'Открыть',
|
|
542
|
+
'release page': 'страница релиза',
|
|
516
543
|
'Reload now': 'Обновить сейчас',
|
|
517
544
|
'Reload the page': 'Обновить страницу',
|
|
518
545
|
'remove': 'убрать',
|
|
@@ -614,6 +641,7 @@ const I18N_RU = {
|
|
|
614
641
|
'The board could not reach the server — the change was not saved': 'доска не достучалась до сервера — правка не сохранена',
|
|
615
642
|
'capped: the task sat in a working column too long': 'урезано: задача слишком долго лежала в рабочей колонке',
|
|
616
643
|
'To do': 'Сделать',
|
|
644
|
+
'this path is not a folder': 'по этому пути не папка',
|
|
617
645
|
'Today': 'Сегодня',
|
|
618
646
|
'Top projects': 'Топ проектов',
|
|
619
647
|
'Tue': 'Вт',
|
|
@@ -635,6 +663,7 @@ const I18N_RU = {
|
|
|
635
663
|
'waiting for the board to come back…': 'жду, пока доска поднимется…',
|
|
636
664
|
'Waiting for you': 'Ждёт тебя',
|
|
637
665
|
'Wed': 'Ср',
|
|
666
|
+
"What's new": 'Что нового',
|
|
638
667
|
'Week': 'Неделя',
|
|
639
668
|
'Weeks': 'Недели',
|
|
640
669
|
'Welcome!': 'Добро пожаловать!',
|
|
@@ -799,6 +828,30 @@ Do not touch my files or servers without asking.`,
|
|
|
799
828
|
};
|
|
800
829
|
export const setupPrompt = () => SETUP_PROMPT[LANG] || SETUP_PROMPT.ru;
|
|
801
830
|
|
|
831
|
+
export const ADD_PROJECT_PROMPT = {
|
|
832
|
+
ru: `Заведи мне проект на канбан-доске (local-kanban). Спрашивай по одному вопросу и жди ответа — ничего не додумывай за меня.
|
|
833
|
+
|
|
834
|
+
1. Спроси, что за проект и где на этом компьютере лежит его код. Проверь, что папка существует, и загляни в неё: посмотри package.json или другой манифест, есть ли git и куда он смотрит.
|
|
835
|
+
2. Если проект куда-то выкатывается — спроси ssh-хост и путь на сервере. ПРОВЕРЬ доступ сам: ssh -o BatchMode=yes <хост> "test -d <путь> && echo ok". Не сработало — скажи мне, в чём дело, и не выдумывай значения.
|
|
836
|
+
3. Спроси, надо ли перезапускать что-то после выкатки (имена процессов pm2) и по какому домену проверять, что всё поднялось.
|
|
837
|
+
4. Реши, каким деплой-скиллом его выкатывать: посмотри kb p и деплой-скиллы соседних проектов, предложи подходящий или «без деплоя». Свой скилл заводится в «Настройках → Скиллы».
|
|
838
|
+
5. Заведи проект через API доски (POST /api/projects) со всем, что выяснили, и покажи мне kb info <ключ>.
|
|
839
|
+
6. Если у проекта есть CLAUDE.md или README — прочитай и предложи 3-5 первых задач в бэклог, но НЕ создавай их без моего «да».
|
|
840
|
+
|
|
841
|
+
Ничего не меняй в самом проекте и на сервере: эта задача — только про запись в реестр доски.`,
|
|
842
|
+
en: `Set up a project on my kanban board (local-kanban). Ask one question at a time and wait for the answer — do not assume anything on my behalf.
|
|
843
|
+
|
|
844
|
+
1. Ask what the project is and where its code lives on this computer. Check that the folder exists and look inside: package.json or another manifest, whether there is a git repo and where it points.
|
|
845
|
+
2. If the project gets deployed somewhere, ask for the ssh host and the path on the server. CHECK the access yourself: ssh -o BatchMode=yes <host> "test -d <path> && echo ok". If it fails, tell me why and do not invent values.
|
|
846
|
+
3. Ask whether anything needs restarting after a deploy (pm2 process names) and which domain to check afterwards.
|
|
847
|
+
4. Decide which deploy skill should ship it: look at kb p and at the deploy skills of neighbouring projects, then suggest one or "no deploy". A skill of your own is created in Settings → Skills.
|
|
848
|
+
5. Register the project through the board API (POST /api/projects) with everything we worked out, and show me kb info <key>.
|
|
849
|
+
6. If the project has a CLAUDE.md or a README, read it and suggest 3-5 first backlog tasks — but do NOT create them without my go-ahead.
|
|
850
|
+
|
|
851
|
+
Change nothing in the project itself and nothing on the server: this task is only about the board's registry.`,
|
|
852
|
+
};
|
|
853
|
+
export const addProjectPrompt = () => ADD_PROJECT_PROMPT[LANG] || ADD_PROJECT_PROMPT.en;
|
|
854
|
+
|
|
802
855
|
export const seg = (s) => encodeURIComponent(String(s));
|
|
803
856
|
export let ghSyncOn = true;
|
|
804
857
|
export function setGhSyncOn(v) { ghSyncOn = v; }
|
package/public/js/project.js
CHANGED
|
@@ -71,8 +71,8 @@ export async function renderProjectSettings(force) {
|
|
|
71
71
|
<small class="ps-hint">${tr('where the code lies on this computer — Claude edits and commits it here')}</small></label>
|
|
72
72
|
<label class="pp-field">${tr('Domain')}<input id="ps-domain" type="text" placeholder="example.com">
|
|
73
73
|
<small class="ps-hint">${tr('opened after a deploy to check the site answers')}</small></label>
|
|
74
|
-
<label class="pp-field">${tr('SSH host')}<input id="ps-server" type="text" placeholder="
|
|
75
|
-
<small class="ps-hint">${tr('
|
|
74
|
+
<label class="pp-field">${tr('SSH host')}<input id="ps-server" type="text" placeholder="192.168.1.10">
|
|
75
|
+
<small class="ps-hint">${tr('no key or password is entered here: access comes from your ~/.ssh/config, and a password will not do — a key is required. If ssh <host> works in your terminal, it works here.')}</small></label>
|
|
76
76
|
<label class="pp-field">${tr('Server path')}<input id="ps-spath" type="text" placeholder="/var/www/my-project">
|
|
77
77
|
<small class="ps-hint">${tr('where the project lies ON THE SERVER — the deploy pulls into this folder')}</small></label>
|
|
78
78
|
<label class="pp-field">${tr('pm2 processes (comma-separated)')}<input id="ps-pm2" type="text" placeholder="my-api, my-web">
|
package/public/js/settings.js
CHANGED
|
@@ -6,6 +6,35 @@ import { checkVersion, pendingReload } from './init.js';
|
|
|
6
6
|
import { loadProjects, popLayer, pushLayer, setupPromptBlockHTML, styledAlert, styledConfirm, styledPrompt, wireSetupPrompt } from './sidebar.js';
|
|
7
7
|
import { SHORT_MONTHS, SOUND_LIB, STATUS_LABELS, audioCtx, lastMissedAt, lastSseAt, missedSounds, pageLoadedAt, playNamedSound, refresh, relTime, titleOr, updateSoundBadge } from './sse.js';
|
|
8
8
|
|
|
9
|
+
function mdInline(text) {
|
|
10
|
+
return esc(text)
|
|
11
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
12
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
13
|
+
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>')
|
|
14
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
15
|
+
}
|
|
16
|
+
export function mdLite(md) {
|
|
17
|
+
const out = [];
|
|
18
|
+
let list = false;
|
|
19
|
+
const closeList = () => { if (list) { out.push('</ul>'); list = false; } };
|
|
20
|
+
for (const block of String(md || '').split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean)) {
|
|
21
|
+
const flat = block.split('\n').map((l) => l.trim()).join(' ');
|
|
22
|
+
const head = flat.match(/^#{1,6}\s+(.*)$/);
|
|
23
|
+
if (head) { closeList(); out.push(`<h4>${mdInline(head[1])}</h4>`); continue; }
|
|
24
|
+
if (/^[-*]\s+/.test(block)) {
|
|
25
|
+
if (!list) { out.push('<ul>'); list = true; }
|
|
26
|
+
for (const item of block.split(/\n(?=[-*]\s)/)) {
|
|
27
|
+
out.push(`<li>${mdInline(item.replace(/^[-*]\s+/, '').split('\n').map((l) => l.trim()).join(' '))}</li>`);
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
closeList();
|
|
32
|
+
out.push(`<p>${mdInline(flat)}</p>`);
|
|
33
|
+
}
|
|
34
|
+
closeList();
|
|
35
|
+
return out.join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
const SETTINGS_DEFAULTS = { sound: true, soundReview: 'ding', soundDone: 'click', startScreen: 'dashboard', dashRange: 'week', theme: 'system', keepDrawer: false, notifyReview: false, compactCards: false, linkLines: true, keymap: {} };
|
|
10
39
|
export function getSetting(k) {
|
|
11
40
|
const v = localStorage.getItem('kb.set.' + k);
|
|
@@ -424,6 +453,7 @@ async function renderSection(sec) {
|
|
|
424
453
|
+ `<div class="set-col hidden" id="about-upd-fail"><span class="set-lab">${tr('Run it yourself')}<small>${tr('the board is not allowed to do this — the command is the same one it just tried')}</small></span>`
|
|
425
454
|
+ '<pre class="onb-code" id="about-upd-cmd"></pre></div>'
|
|
426
455
|
+ `<div class="set-row hidden" id="about-dev-row"><span class="set-lab">${tr('dev branch')}<small id="about-dev-sub">…</small></span><span class="about-fresh" id="about-dev"></span></div>`
|
|
456
|
+
+ `<div class="set-col"><span class="set-lab">${tr("What's new")}<small id="about-news-sub">…</small></span><div class="about-news" id="about-news">…</div></div>`
|
|
427
457
|
+ `<div class="set-row"><span class="set-lab">${tr('Check for updates')}<small id="about-recheck-sub">${tr('re-read branch state from GitHub')}</small></span><button class="btn-ghost" id="about-recheck">${tr('Check')}</button></div>`
|
|
428
458
|
+ `<div class="set-row"><span class="set-lab">${tr('Reload the page')}<small>${tr('re-read the board code right now')}</small></span><button class="btn-ghost" id="about-reload">${tr('Reload now')}</button></div>`
|
|
429
459
|
+ `<div class="set-row"><span class="set-lab">${tr('Using the board at work')}<small>${tr('free for everyone, nothing switched off — if it earns you money, $12 a year per person keeps it maintained')}</small></span>`
|
|
@@ -475,8 +505,21 @@ async function renderSection(sec) {
|
|
|
475
505
|
? `${u.dev.sha}${u.dev.date ? ` ${tr('at')} ${fmtDbTime(u.dev.date.slice(0, 19).replace('T', ' '))}` : ''}${u.dev.message ? ` · ${u.dev.message}` : ''}`
|
|
476
506
|
: tr('dev has no commits beyond main');
|
|
477
507
|
};
|
|
508
|
+
const loadNews = async (upd, forced) => {
|
|
509
|
+
const box = $('about-news'); const sub = $('about-news-sub');
|
|
510
|
+
if (!box || !sub) return;
|
|
511
|
+
try {
|
|
512
|
+
const remote = upd?.update_available === true ? `?remote=1${forced ? '&refresh=1' : ''}` : '';
|
|
513
|
+
const n = await api('GET', `/api/whats-new${remote}`, null, { quiet: true });
|
|
514
|
+
box.innerHTML = n?.notes ? mdLite(n.notes) : `<p class="muted">${esc(tr('no notes for this version'))}</p>`;
|
|
515
|
+
const ver = n?.version ? `${tr('version')} ${n.version}${n.source === 'github' ? ` · ${tr('not installed yet')}` : ''}` : '';
|
|
516
|
+
const link = /^https:\/\//.test(n?.url || '')
|
|
517
|
+
? ` · <a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer">${esc(tr('release page'))}</a>` : '';
|
|
518
|
+
sub.innerHTML = esc(ver) + link;
|
|
519
|
+
} catch { box.textContent = '—'; sub.textContent = ''; }
|
|
520
|
+
};
|
|
478
521
|
api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
|
|
479
|
-
.then(() => api('GET', '/api/update-check')).then(renderUpd)
|
|
522
|
+
.then(() => api('GET', '/api/update-check')).then((u) => { renderUpd(u); return loadNews(u); })
|
|
480
523
|
.catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
|
|
481
524
|
const showFailCmd = (cmd) => {
|
|
482
525
|
const wrap = $('about-upd-fail');
|
|
@@ -531,7 +574,7 @@ async function renderSection(sec) {
|
|
|
531
574
|
$('about-recheck').onclick = async () => {
|
|
532
575
|
const btn = $('about-recheck');
|
|
533
576
|
btn.disabled = true; $('about-upd-sub').textContent = tr('checking…');
|
|
534
|
-
try {
|
|
577
|
+
try { const u = await api('GET', '/api/update-check?refresh=1'); renderUpd(u); await loadNews(u, true); }
|
|
535
578
|
catch { $('about-upd-sub').textContent = '—'; }
|
|
536
579
|
btn.disabled = false;
|
|
537
580
|
};
|
package/public/js/sidebar.js
CHANGED
|
@@ -3,7 +3,7 @@ import { buildSelect, renderBoard, selVal } from './board.js';
|
|
|
3
3
|
|
|
4
4
|
const GENERIC_DEPLOY = 'deploy';
|
|
5
5
|
import { renderChaos } from './chaos.js';
|
|
6
|
-
import { $, ALL, CALENDAR, CHAOS, DASH, HIDDEN_SECTIONS, HORIZON, LANG, SETTINGS, SIDEBAR_SECTIONS, api, esc, ic, seg, setupPrompt, state, tr } from './core.js';
|
|
6
|
+
import { $, ALL, CALENDAR, CHAOS, DASH, HIDDEN_SECTIONS, HORIZON, LANG, SETTINGS, SIDEBAR_SECTIONS, addProjectPrompt, api, esc, ic, seg, setupPrompt, state, tr } from './core.js';
|
|
7
7
|
import { renderDashboard } from './dash.js';
|
|
8
8
|
import { renderCalendar, renderHorizon } from './horizon.js';
|
|
9
9
|
import { openProjectSettings, renderProjectSettings, skillOptions, deploySkills } from './project.js';
|
|
@@ -346,6 +346,66 @@ function wizSlugify(name) {
|
|
|
346
346
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
function setState(id, text, kind) {
|
|
350
|
+
const el = $(id);
|
|
351
|
+
if (!el) return;
|
|
352
|
+
el.textContent = text || '';
|
|
353
|
+
el.classList.remove('ok', 'bad');
|
|
354
|
+
if (text && kind) el.classList.add(kind);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function wizPrefix(slug, used = new Set(state.projects.map((p) => p.prefix).filter(Boolean))) {
|
|
358
|
+
const words = slug.replace(/[^a-z0-9-]/gi, '').split('-').filter(Boolean);
|
|
359
|
+
let base = words.length >= 2
|
|
360
|
+
? words.map((w) => w[0]).join('').slice(0, 3).toUpperCase()
|
|
361
|
+
: slug.slice(0, 3).toUpperCase();
|
|
362
|
+
if (base.length < 2) base = (base + 'XX').slice(0, 2);
|
|
363
|
+
let prefix = base;
|
|
364
|
+
let n = 2;
|
|
365
|
+
while (used.has(prefix)) prefix = base + n++;
|
|
366
|
+
return prefix;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function checkPath(path) {
|
|
370
|
+
if (!path) { setState('wiz-path-state', tr('give the folder path'), 'bad'); return false; }
|
|
371
|
+
setState('wiz-path-state', tr('checking…'));
|
|
372
|
+
try {
|
|
373
|
+
const r = await api('GET', `/api/fs?path=${encodeURIComponent(path)}`, null, { quiet: true });
|
|
374
|
+
setState('wiz-path-state', `${tr('the folder is there')} · ${r.dirs.length} ${tr('subfolders')}`, 'ok');
|
|
375
|
+
return true;
|
|
376
|
+
} catch (e) {
|
|
377
|
+
const said = { 404: tr('no such folder on this computer'),
|
|
378
|
+
400: tr('this path is not a folder'),
|
|
379
|
+
403: tr('the folder is there, but the board cannot read it — check its permissions') }[e.status];
|
|
380
|
+
setState('wiz-path-state', said || `${tr('could not check the folder')}: ${e.message || path}`, 'bad');
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const SSH_WHY = {
|
|
386
|
+
unknown_host: 'no such host — check the name, or add it to your ~/.ssh/config',
|
|
387
|
+
key_refused: 'the server refused the key — a password will not help here, the key has to be authorized',
|
|
388
|
+
host_key: 'the host key is unknown — connect once from your terminal and confirm the fingerprint',
|
|
389
|
+
unreachable: 'the server did not answer — wrong address, wrong port, or it is down',
|
|
390
|
+
failed: 'could not connect',
|
|
391
|
+
};
|
|
392
|
+
export async function checkSsh() {
|
|
393
|
+
const server = $('wiz-server')?.value.trim();
|
|
394
|
+
const path = $('wiz-spath')?.value.trim();
|
|
395
|
+
if (!server) { setState('wiz-ssh-state', tr('give the ssh host'), 'bad'); return; }
|
|
396
|
+
const btn = $('wiz-ssh-check');
|
|
397
|
+
if (btn) btn.disabled = true;
|
|
398
|
+
setState('wiz-ssh-state', tr('connecting…'));
|
|
399
|
+
try {
|
|
400
|
+
const r = await api('POST', '/api/projects/ssh-check', { server, path }, { quiet: true });
|
|
401
|
+
const dir = r.path_exists === null ? '' : ` · ${r.path_exists ? tr('the server path is there') : tr('but there is no such path on the server')}`;
|
|
402
|
+
setState('wiz-ssh-state', `${tr('connected')}${dir}`, r.path_exists === false ? 'bad' : 'ok');
|
|
403
|
+
} catch (e) {
|
|
404
|
+
setState('wiz-ssh-state', tr(SSH_WHY[e.body?.reason] || SSH_WHY.failed), 'bad');
|
|
405
|
+
}
|
|
406
|
+
if (btn) btn.disabled = false;
|
|
407
|
+
}
|
|
408
|
+
|
|
349
409
|
export async function openFolderPicker() {
|
|
350
410
|
const box = $('wiz-browser'); const input = $('wiz-path');
|
|
351
411
|
if (!box || !input) return;
|
|
@@ -399,10 +459,21 @@ function openProjectWizard() {
|
|
|
399
459
|
const stepType = () => {
|
|
400
460
|
err('');
|
|
401
461
|
body.innerHTML = WIZ_TYPES.map(([id, title, sub]) =>
|
|
402
|
-
`<button type="button" class="wiz-type" data-type="${id}"><b>${esc(title)}</b><small>${esc(sub)}</small></button>`).join('')
|
|
462
|
+
`<button type="button" class="wiz-type" data-type="${id}"><b>${esc(title)}</b><small>${esc(sub)}</small></button>`).join('')
|
|
463
|
+
+ `<div class="wiz-h">${tr('or let Claude do it')}</div>`
|
|
464
|
+
+ `<div class="kbh-note muted">${tr('Claude looks at the folder itself, checks the server access, and asks you one question at a time. Paste this into your Claude Code chat.')}</div>`
|
|
465
|
+
+ `<div class="setup-prompt"><pre class="onb-code setup-prompt-text">${esc(addProjectPrompt())}</pre>`
|
|
466
|
+
+ `<button type="button" class="btn-ghost wiz-ai-copy">${ic('copy', 13)} ${tr('Copy the prompt')}</button></div>`;
|
|
403
467
|
actions.innerHTML = `<button class="btn-ghost wiz-cancel">${tr('Cancel')}</button>`;
|
|
404
468
|
actions.querySelector('.wiz-cancel').onclick = close;
|
|
405
469
|
body.querySelectorAll('.wiz-type').forEach((b) => { b.onclick = () => stepForm(b.dataset.type); });
|
|
470
|
+
const aiBtn = body.querySelector('.wiz-ai-copy');
|
|
471
|
+
aiBtn.onclick = async () => {
|
|
472
|
+
const okCopy = await copyText(addProjectPrompt());
|
|
473
|
+
const was = aiBtn.innerHTML;
|
|
474
|
+
aiBtn.innerHTML = okCopy ? `✓ ${tr('Copied')}` : `✕ ${tr('did not work — select and copy manually')}`;
|
|
475
|
+
setTimeout(() => { aiBtn.innerHTML = was; }, 2000);
|
|
476
|
+
};
|
|
406
477
|
};
|
|
407
478
|
|
|
408
479
|
const stepForm = (type) => {
|
|
@@ -411,21 +482,28 @@ function openProjectWizard() {
|
|
|
411
482
|
const withLocal = type !== 'server';
|
|
412
483
|
const folders = state.folders.unregistered || [];
|
|
413
484
|
let html = `<label class="pp-field">${tr('Project name')}<input id="wiz-name" type="text" placeholder="my project"></label>`
|
|
414
|
-
+ `<label class="pp-field">${tr('Key (slug + task prefix)')}<input id="wiz-slug" type="text" placeholder="my-project"
|
|
485
|
+
+ `<label class="pp-field">${tr('Key (slug + task prefix)')}<input id="wiz-slug" type="text" placeholder="my-project">`
|
|
486
|
+
+ `<small class="pp-hint" id="wiz-slug-state"></small></label>`;
|
|
415
487
|
if (withLocal) {
|
|
416
488
|
html += `<div class="pp-field">${tr('Project folder')}
|
|
417
489
|
<div id="wiz-folder"></div>
|
|
418
490
|
<div id="wiz-path-row" class="wiz-path-row hidden">
|
|
419
491
|
<input id="wiz-path" type="text" placeholder="/full/path/to/the/folder">
|
|
420
492
|
<button type="button" class="btn-ghost" id="wiz-browse">${tr('Choose…')}</button>
|
|
493
|
+
<button type="button" class="btn-ghost" id="wiz-path-check">${tr('Check')}</button>
|
|
421
494
|
</div>
|
|
495
|
+
<small class="pp-hint" id="wiz-path-state"></small>
|
|
422
496
|
<div id="wiz-browser" class="wiz-browser hidden"></div>
|
|
423
|
-
<input id="wiz-git" type="text" class="hidden" placeholder="git@github.com:me/repo.git"
|
|
497
|
+
<input id="wiz-git" type="text" class="hidden" placeholder="git@github.com:me/repo.git">
|
|
498
|
+
<small class="pp-hint hidden" id="wiz-git-hint"></small></div>`;
|
|
424
499
|
}
|
|
425
500
|
if (withServer) {
|
|
426
|
-
html += `<div class="
|
|
427
|
-
+ `<
|
|
428
|
-
+ `<
|
|
501
|
+
html += `<div class="wiz-h">${tr('Server')}</div>`
|
|
502
|
+
+ `<div class="kbh-note muted">${tr('Not about this computer. Empty means the project simply stays local.')}</div>`
|
|
503
|
+
+ `<div class="pp-field">${tr('SSH host')}`
|
|
504
|
+
+ `<div class="wiz-path-row"><input id="wiz-server" type="text" placeholder="192.168.1.10">`
|
|
505
|
+
+ `<button type="button" class="btn-ghost" id="wiz-ssh-check">${tr('Check')}</button></div>`
|
|
506
|
+
+ `<small class="pp-hint" id="wiz-ssh-state">${tr('no key or password is entered here: access comes from your ~/.ssh/config, and a password will not do — a key is required. If ssh <host> works in your terminal, it works here.')}</small></div>`
|
|
429
507
|
+ `<label class="pp-field">${tr('Server path')}<input id="wiz-spath" type="text" placeholder="/var/www/my-project"></label>`
|
|
430
508
|
+ `<label class="pp-field">${tr('pm2 processes (comma-separated)')}<input id="wiz-pm2" type="text" placeholder="my-api, my-web"></label>`
|
|
431
509
|
+ `<label class="pp-field">${tr('Domain (to verify after deploy)')}<input id="wiz-domain" type="text" placeholder="example.com"></label>`
|
|
@@ -438,8 +516,19 @@ function openProjectWizard() {
|
|
|
438
516
|
const nameEl = $('wiz-name');
|
|
439
517
|
const slugEl = $('wiz-slug');
|
|
440
518
|
let slugTouched = false;
|
|
441
|
-
|
|
442
|
-
|
|
519
|
+
const showSlugState = () => {
|
|
520
|
+
const el = $('wiz-slug-state');
|
|
521
|
+
if (!el) return;
|
|
522
|
+
const v = slugEl.value.trim();
|
|
523
|
+
const taken = v && state.projects.find((p) => p.slug === v);
|
|
524
|
+
el.classList.remove('ok', 'bad');
|
|
525
|
+
if (!v) { el.textContent = ''; return; }
|
|
526
|
+
if (!/^[a-z0-9-]{2,40}$/.test(v)) { el.textContent = tr('key: lowercase latin letters, digits and hyphens'); el.classList.add('bad'); return; }
|
|
527
|
+
el.textContent = taken ? `${tr('this key is already taken by')} ${taken.name || taken.slug}` : `${tr('key is free')} · ${tr('task prefix')} ${wizPrefix(v)}`;
|
|
528
|
+
el.classList.add(taken ? 'bad' : 'ok');
|
|
529
|
+
};
|
|
530
|
+
slugEl.oninput = () => { slugTouched = true; showSlugState(); };
|
|
531
|
+
nameEl.oninput = () => { if (!slugTouched) slugEl.value = wizSlugify(nameEl.value); showSlugState(); };
|
|
443
532
|
const folderSel = $('wiz-folder');
|
|
444
533
|
if (folderSel) {
|
|
445
534
|
const options = [
|
|
@@ -449,13 +538,29 @@ function openProjectWizard() {
|
|
|
449
538
|
];
|
|
450
539
|
const onFolderChange = (v) => {
|
|
451
540
|
$('wiz-path-row').classList.toggle('hidden', v !== '__manual__');
|
|
541
|
+
$('wiz-path-state').classList.toggle('hidden', v !== '__manual__');
|
|
452
542
|
$('wiz-browser').classList.add('hidden');
|
|
453
543
|
$('wiz-git').classList.toggle('hidden', v !== '__clone__');
|
|
544
|
+
const gh = $('wiz-git-hint');
|
|
545
|
+
gh.classList.toggle('hidden', v !== '__clone__');
|
|
546
|
+
if (v === '__clone__') {
|
|
547
|
+
const root = state.folders.root || '~';
|
|
548
|
+
const key = $('wiz-slug').value.trim() || 'my-project';
|
|
549
|
+
gh.textContent = `${tr('on create, git clone puts it on THIS computer at')} ${root}/${key}. `
|
|
550
|
+
+ tr('An existing folder is refused, not overwritten. Git credentials are not asked here — access comes from your own git and ssh setup.');
|
|
551
|
+
}
|
|
454
552
|
};
|
|
455
553
|
const start = type === 'local' ? '' : '__manual__';
|
|
456
554
|
buildSelect(folderSel, { value: start, options, onChange: onFolderChange });
|
|
457
555
|
onFolderChange(start);
|
|
458
556
|
$('wiz-browse').onclick = openFolderPicker;
|
|
557
|
+
$('wiz-path-check').onclick = () => checkPath($('wiz-path').value.trim());
|
|
558
|
+
$('wiz-path').oninput = () => setState('wiz-path-state', '');
|
|
559
|
+
}
|
|
560
|
+
if ($('wiz-ssh-check')) {
|
|
561
|
+
$('wiz-ssh-check').onclick = checkSsh;
|
|
562
|
+
$('wiz-server').oninput = () => setState('wiz-ssh-state', '');
|
|
563
|
+
$('wiz-spath').oninput = () => setState('wiz-ssh-state', '');
|
|
459
564
|
}
|
|
460
565
|
const skillHost = $('wiz-skill');
|
|
461
566
|
if (skillHost) {
|
package/public/style.css
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
--done: #22c55e;
|
|
23
23
|
--cancelled: #ef4444;
|
|
24
24
|
--danger: #dc2626;
|
|
25
|
+
--ok: #15803d;
|
|
25
26
|
--hover: #f5f5f4;
|
|
26
27
|
--hover-strong: #f4f4f2;
|
|
27
28
|
--chip-bg: #fafaf9;
|
|
@@ -146,6 +147,9 @@ a.btn-ghost { text-decoration: none; }
|
|
|
146
147
|
.sync-about .muted, .sync-about p.muted { color: var(--muted); }
|
|
147
148
|
.sync-about code { font-size: 11.5px; background: var(--chip-bg); padding: 1px 5px; border-radius: 5px; }
|
|
148
149
|
.pp-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 11.5px; }
|
|
150
|
+
.pp-hint.ok { color: var(--ok); }
|
|
151
|
+
.pp-hint.bad, .ps-hint.bad { color: var(--danger); }
|
|
152
|
+
.ps-hint.ok { color: var(--ok); }
|
|
149
153
|
.wiz-path-row { display: flex; gap: 8px; align-items: center; }
|
|
150
154
|
.wiz-path-row input { flex: 1; min-width: 0; }
|
|
151
155
|
.wiz-browser {
|
|
@@ -613,8 +617,8 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
613
617
|
.modal-err { font-size: 12px; color: var(--danger); }
|
|
614
618
|
.modal-actions { display: flex; gap: 8px; }
|
|
615
619
|
|
|
616
|
-
.modal.wiz { width:
|
|
617
|
-
.wiz-body { display: flex; flex-direction: column; gap:
|
|
620
|
+
.modal.wiz { width: 460px; max-width: calc(100vw - 32px); }
|
|
621
|
+
.wiz-body { display: flex; flex-direction: column; gap: 7px; max-height: calc(100vh - 170px); overflow-y: auto; }
|
|
618
622
|
.wiz-type {
|
|
619
623
|
display: flex; flex-direction: column; gap: 3px; text-align: left; padding: 10px 12px;
|
|
620
624
|
border: 1px solid var(--border); border-radius: 9px; background: var(--panel); cursor: pointer; font: inherit; color: var(--text);
|
|
@@ -946,6 +950,8 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
946
950
|
.kbh-remap:hover kbd { border-color: var(--accent); }
|
|
947
951
|
.kbh-remap.listening kbd { border-color: var(--accent); color: var(--accent); }
|
|
948
952
|
.kbh-note { font-size: 11.5px; margin-top: 10px; }
|
|
953
|
+
.wiz-h { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-top: 6px; }
|
|
954
|
+
.wiz-h + .kbh-note { margin-top: 0; }
|
|
949
955
|
.about-fresh { font-size: 12px; color: var(--muted); flex-shrink: 0; }
|
|
950
956
|
.about-fresh.stale-ver { color: var(--accent); }
|
|
951
957
|
#about-diag .bk-row span:first-child { white-space: nowrap; }
|
|
@@ -989,6 +995,19 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
989
995
|
.theme-opt { border: none; background: var(--panel); color: var(--muted); font: inherit; font-size: 12.5px; padding: 6px 12px; cursor: pointer; }
|
|
990
996
|
.theme-opt + .theme-opt { border-left: 1px solid var(--border); }
|
|
991
997
|
.theme-opt.active { background: var(--accent); color: #fff; }
|
|
998
|
+
.about-news {
|
|
999
|
+
max-height: 260px; overflow-y: auto; font-size: 12.5px; line-height: 1.5;
|
|
1000
|
+
border: 1px solid var(--border-soft); border-radius: 8px; padding: 10px 12px; background: var(--panel);
|
|
1001
|
+
}
|
|
1002
|
+
.about-news h4 { font-size: 12.5px; font-weight: 600; margin: 12px 0 6px; color: var(--muted); }
|
|
1003
|
+
.about-news h4:first-child { margin-top: 0; }
|
|
1004
|
+
.about-news p { margin: 0 0 8px; }
|
|
1005
|
+
.about-news ul { margin: 0 0 8px; padding-left: 18px; }
|
|
1006
|
+
.about-news li { margin-bottom: 7px; }
|
|
1007
|
+
.about-news li:last-child, .about-news p:last-child { margin-bottom: 0; }
|
|
1008
|
+
.about-news code { font-size: 11.5px; background: var(--border-soft); border-radius: 4px; padding: 1px 4px; }
|
|
1009
|
+
.about-news a { color: var(--accent); }
|
|
1010
|
+
|
|
992
1011
|
.bk-list { display: flex; flex-direction: column; gap: 0; margin-top: 6px; }
|
|
993
1012
|
.bk-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding: 7px 0; border-top: 1px solid var(--border-soft); }
|
|
994
1013
|
.bk-row:first-child { border-top: none; }
|
|
@@ -1129,6 +1148,7 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
1129
1148
|
--field-text: #c7c0b3;
|
|
1130
1149
|
--warn-bg: #3a3320; --warn-border: #5a4d22; --warn-text: #e6c46e;
|
|
1131
1150
|
--danger: #f0716b;
|
|
1151
|
+
--ok: #6fbf85;
|
|
1132
1152
|
--danger-soft: #3a2222;
|
|
1133
1153
|
--tl-created: #4d4940;
|
|
1134
1154
|
--shadow: rgba(0, 0, 0, .55);
|
|
@@ -1155,7 +1175,7 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
1155
1175
|
--border-hover: #4a4946;
|
|
1156
1176
|
--field-text: #c7c4ba;
|
|
1157
1177
|
--warn-bg: #3a3320; --warn-border: #5a4d22; --warn-text: #e6c46e;
|
|
1158
|
-
--danger: #e8706a; --danger-soft: #3a2222;
|
|
1178
|
+
--danger: #e8706a; --danger-soft: #3a2222; --ok: #6fbf85;
|
|
1159
1179
|
--tl-created: #4a4946;
|
|
1160
1180
|
--shadow: rgba(0, 0, 0, .55);
|
|
1161
1181
|
--input-bg: #1d1d1c;
|
package/src/routes/projects.js
CHANGED
|
@@ -440,6 +440,28 @@ export default async function projectRoutes(app) {
|
|
|
440
440
|
return { git: !r.err && r.out === 'true' };
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
app.post('/api/projects/ssh-check', async (req, reply) => {
|
|
444
|
+
const server = String(req.body?.server || '').trim();
|
|
445
|
+
const path = String(req.body?.path || '').trim();
|
|
446
|
+
if (!server) return reply.code(400).send({ error: 'server is required' });
|
|
447
|
+
if (!/^[\w.@][\w.@-]*$/.test(server)) return reply.code(400).send({ error: 'server: an ssh alias or host, no options or spaces' });
|
|
448
|
+
if (path && /["'`;|&$\\]/.test(path)) return reply.code(400).send({ error: 'path: invalid characters' });
|
|
449
|
+
const cmd = path ? `test -d "${path}" && echo dir || echo nodir` : 'echo ok';
|
|
450
|
+
const res = await new Promise((done) => execFile('ssh',
|
|
451
|
+
['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '--', server, cmd],
|
|
452
|
+
{ timeout: 20000 }, (err, out, serr) => done({ err, out: String(out || '').trim(), serr: String(serr || '').trim() })));
|
|
453
|
+
if (res.err) {
|
|
454
|
+
const t = res.serr;
|
|
455
|
+
const why = /permission denied|publickey/i.test(t) ? 'key_refused'
|
|
456
|
+
: /host key verification/i.test(t) ? 'host_key'
|
|
457
|
+
: /could not resolve|name or service not known|no address/i.test(t) ? 'unknown_host'
|
|
458
|
+
: /connection refused|timed out|no route/i.test(t) || res.err.killed ? 'unreachable'
|
|
459
|
+
: 'failed';
|
|
460
|
+
return reply.code(502).send({ ok: false, reason: why, detail: t.split('\n').filter(Boolean).at(-1) || res.err.message });
|
|
461
|
+
}
|
|
462
|
+
return { ok: true, path_exists: path ? res.out.includes('dir') && !res.out.includes('nodir') : null };
|
|
463
|
+
});
|
|
464
|
+
|
|
443
465
|
const DEMO_TEXTS = {
|
|
444
466
|
ru: {
|
|
445
467
|
name: 'Демо-проект',
|
package/src/routes/system.js
CHANGED
|
@@ -20,8 +20,8 @@ let selfPkgCache;
|
|
|
20
20
|
function selfPkg() {
|
|
21
21
|
if (selfPkgCache === undefined) {
|
|
22
22
|
try {
|
|
23
|
-
const { name, version } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
24
|
-
selfPkgCache = name && version ? { name, version } : null;
|
|
23
|
+
const { name, version, repository } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
24
|
+
selfPkgCache = name && version ? { name, version, repository: repository?.url || repository || null } : null;
|
|
25
25
|
} catch { selfPkgCache = null; }
|
|
26
26
|
}
|
|
27
27
|
return selfPkgCache;
|
|
@@ -82,6 +82,32 @@ export async function registryCheck() {
|
|
|
82
82
|
} catch { return null; }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
export async function ghApi(path) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`https://api.github.com/${path}`, {
|
|
88
|
+
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
89
|
+
signal: AbortSignal.timeout(8000),
|
|
90
|
+
});
|
|
91
|
+
if (res.ok) return await res.json();
|
|
92
|
+
} catch { }
|
|
93
|
+
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
94
|
+
catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function changelogSection(text, version) {
|
|
98
|
+
const head = new RegExp(`^##\\s*\\[?${String(version).replace(/\./g, '\\.')}\\]?`, 'm');
|
|
99
|
+
const m = head.exec(String(text || ''));
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
const rest = text.slice(m.index + m[0].length);
|
|
102
|
+
const end = rest.search(/^## /m);
|
|
103
|
+
return (end === -1 ? rest : rest.slice(0, end)).trim() || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function repoSlug(url) {
|
|
107
|
+
const m = String(url || '').match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#.*)?$/);
|
|
108
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
const UPLOAD_FLOOR = 64 * 1024 * 1024;
|
|
86
112
|
function uploadLimit() {
|
|
87
113
|
try { return Math.max(UPLOAD_FLOOR, statSync(join(DATA_DIR, 'kanban.db')).size * 4); } catch { return UPLOAD_FLOOR; }
|
|
@@ -255,17 +281,6 @@ export default async function systemRoutes(app) {
|
|
|
255
281
|
const git = (args) => new Promise((resolve) => {
|
|
256
282
|
execFile('git', args, { cwd: ROOT, timeout: 8000 }, (err, out) => resolve(err ? null : out.trim()));
|
|
257
283
|
});
|
|
258
|
-
const ghApi = async (path) => {
|
|
259
|
-
try {
|
|
260
|
-
const res = await fetch(`https://api.github.com/${path}`, {
|
|
261
|
-
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
262
|
-
signal: AbortSignal.timeout(8000),
|
|
263
|
-
});
|
|
264
|
-
if (res.ok) return await res.json();
|
|
265
|
-
} catch { }
|
|
266
|
-
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
267
|
-
catch { return null; }
|
|
268
|
-
};
|
|
269
284
|
let data = { update_available: null, local: null, remote: null, branch: null, tag: null, dev: null };
|
|
270
285
|
try {
|
|
271
286
|
const [local, branch, origin, tag] = await Promise.all([
|
|
@@ -300,6 +315,30 @@ export default async function systemRoutes(app) {
|
|
|
300
315
|
return data;
|
|
301
316
|
});
|
|
302
317
|
|
|
318
|
+
const NEWS_OK_MS = 3600000;
|
|
319
|
+
const NEWS_FAIL_MS = 60000;
|
|
320
|
+
let newsCache = { at: 0, ttl: 0, data: null };
|
|
321
|
+
app.get('/api/whats-new', async (req) => {
|
|
322
|
+
const version = selfPkg()?.version || null;
|
|
323
|
+
const own = () => {
|
|
324
|
+
let text = null;
|
|
325
|
+
try { text = readFileSync(join(ROOT_DIR, 'CHANGELOG.md'), 'utf8'); } catch { }
|
|
326
|
+
return { version, notes: (text && version) ? changelogSection(text, version) : null, source: 'changelog', url: null };
|
|
327
|
+
};
|
|
328
|
+
if (!req.query?.remote) return own();
|
|
329
|
+
if (!req.query?.refresh && newsCache.data && Date.now() - newsCache.at < newsCache.ttl) return newsCache.data;
|
|
330
|
+
const repo = repoSlug(selfPkg()?.repository);
|
|
331
|
+
const rel = repo ? await ghApi(`repos/${repo}/releases/latest`) : null;
|
|
332
|
+
const tag = String(rel?.tag_name || '').replace(/^v/, '');
|
|
333
|
+
const body = String(rel?.body || '').trim();
|
|
334
|
+
const got = Boolean(tag && body);
|
|
335
|
+
const data = got
|
|
336
|
+
? { version: tag, notes: body, source: 'github', url: rel.html_url || null }
|
|
337
|
+
: own();
|
|
338
|
+
newsCache = { at: Date.now(), ttl: got ? NEWS_OK_MS : NEWS_FAIL_MS, data };
|
|
339
|
+
return data;
|
|
340
|
+
});
|
|
341
|
+
|
|
303
342
|
let updateRunning = null;
|
|
304
343
|
app.post('/api/update', async (req, reply) => {
|
|
305
344
|
if (updateRunning) return reply.code(409).send({ error: 'an update is already running' });
|