local-kanban 1.8.3 → 1.9.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 +55 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/package.json +1 -1
- package/public/app.js +2 -1
- package/public/js/core.js +21 -1
- package/public/js/project.js +9 -6
- package/public/js/settings.js +37 -5
- package/public/js/sidebar.js +76 -16
- package/public/style.css +17 -0
- package/src/routes/projects.js +42 -0
- package/src/routes/system.js +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,61 @@
|
|
|
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
|
+
## [1.9.0]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **You can pick the project folder instead of typing it.** The wizard offered a list of folders
|
|
11
|
+
from one directory and, past that, a text field where a typo produced a project pointing nowhere.
|
|
12
|
+
There is now a "Choose…" button: it opens the real system folder dialog, and where there isn't one
|
|
13
|
+
(a server with no desktop, another OS) the board falls back to its own folder browser. Either way
|
|
14
|
+
the path is checked before the project is created. A browser page cannot read an absolute path by
|
|
15
|
+
itself, so the board's own server does the looking.
|
|
16
|
+
|
|
17
|
+
- **The sync section explains itself now.** It used to be two empty fields and no context: what
|
|
18
|
+
this does, whether it applies to one project or the whole board, and what exactly ends up on
|
|
19
|
+
GitHub. It now says all of it before you type anything, including the part that matters most:
|
|
20
|
+
the repository holds the issues for the whole board, you should make it private, and task titles,
|
|
21
|
+
descriptions and comments are copied there in full. That is the only feature here that sends
|
|
22
|
+
anything off your machine, so it says so out loud.
|
|
23
|
+
|
|
24
|
+
- **The deploy skill is picked from a list now.** The wizard asked for a "custom deploy skill" as
|
|
25
|
+
free text, which assumes you already know which skills you have, and an empty field quietly turned
|
|
26
|
+
into the shared `deploy` skill: nobody chose it, it was simply assigned. The list shows deploy
|
|
27
|
+
skills rather than everything in your skills folder, says where to make your own, and "not set"
|
|
28
|
+
stays a real answer, because a project without a deploy is a normal project.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **The folder dropdown was drawn by the operating system**, which ignores everything about how the
|
|
33
|
+
rest of the board looks. It now uses the same control as every other dropdown here. The field was
|
|
34
|
+
also labelled "Project key", the same words as the task-key field two rows above it.
|
|
35
|
+
|
|
36
|
+
- **A failed update now ends with the command to run.** The most common way an update fails is that
|
|
37
|
+
the global package directory is not writable, and there is nothing the board can do about that
|
|
38
|
+
by itself — only you, in a terminal, can. It used to answer with the path to an npm debug log,
|
|
39
|
+
because it picked the longest line npm marked as an error, and npm's longest lines are its own
|
|
40
|
+
stack frames and file paths. It now names the cause and shows the exact command it just tried,
|
|
41
|
+
with the same `--prefix`, so the one you run updates the copy that is actually running.
|
|
42
|
+
|
|
43
|
+
- **The update dialog says what happens to your work.** Two things you cannot check for yourself:
|
|
44
|
+
your tasks are untouched, because they live in the data directory apart from the code, and — for
|
|
45
|
+
an npm install — the Claude Code skills are a *copy*, so after updating they need
|
|
46
|
+
`local-kanban skills`. Without that Claude keeps reading the instructions from the old version
|
|
47
|
+
and gives no sign of it.
|
|
48
|
+
|
|
49
|
+
- **The sync section links to the README**, where the same thing is written out in more detail.
|
|
50
|
+
|
|
51
|
+
## [1.8.4]
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **Releases publish themselves again, and carry provenance.** Publishing is now authorised by a
|
|
56
|
+
trusted publisher — npm trusts this repository's release workflow directly — instead of a token
|
|
57
|
+
that the account's two-factor policy would not accept anyway. Every version from here on is signed
|
|
58
|
+
by the build that produced it, which anyone can check on the package page; 1.6.3 through 1.8.3 were
|
|
59
|
+
published by hand and carry no such signature.
|
|
60
|
+
|
|
6
61
|
## [1.8.3]
|
|
7
62
|
|
|
8
63
|
### Added
|
package/README.md
CHANGED
|
@@ -94,6 +94,8 @@ ln -s "$(pwd)/skills/deploy" ~/.claude/skills/deploy
|
|
|
94
94
|
|
|
95
95
|
Set the owner and issues repo in **Settings → Sync** on the board (or env `KB_GH_OWNER` / `KB_GH_REPO`). Without them the board runs in local-only mode: no queue, no warnings. With them, every task becomes an issue and every project gets a `kb: <slug>` GitHub Project with matching columns. Sync is one-way (board → GitHub) and runs in the background.
|
|
96
96
|
|
|
97
|
+
One repository holds the issues for the whole board, not one per project, and you create it yourself before filling the fields. **Make it private:** titles, descriptions and comments are copied there in full, and this is the only feature that sends anything off your machine. The `gh` CLI has to be signed in with the `project` scope (`gh auth login && gh auth refresh -s project`) - without that scope the issue is still created, but the card never appears on the Projects board, which is a confusing way to find out.
|
|
98
|
+
|
|
97
99
|
## Updating
|
|
98
100
|
|
|
99
101
|
From npm:
|
package/README.ru.md
CHANGED
|
@@ -94,6 +94,8 @@ ln -s "$(pwd)/skills/deploy" ~/.claude/skills/deploy
|
|
|
94
94
|
|
|
95
95
|
Укажи owner и репозиторий для issues в **Настройки → Синхронизация** (или env `KB_GH_OWNER` / `KB_GH_REPO`). Без них доска работает в local-only режиме: без очереди и предупреждений. С ними каждая задача становится issue, а каждый проект получает GitHub Project `kb: <slug>` с теми же колонками. Синк односторонний (доска → GitHub), фоновый.
|
|
96
96
|
|
|
97
|
+
Issues всей доски лежат в ОДНОМ репозитории, а не по одному на проект, и создать его надо самому до того, как заполнять поля. **Сделай его приватным:** заголовки, описания и комментарии уезжают туда целиком, и это единственная функция, которая вообще что-то отправляет с твоей машины. `gh` должен быть авторизован со scope `project` (`gh auth login && gh auth refresh -s project`) - без него issue создастся, а карточка в Projects не появится, и понять причину будет непросто.
|
|
98
|
+
|
|
97
99
|
## Обновление
|
|
98
100
|
|
|
99
101
|
Доска сама замечает новую версию и предлагает кнопку в **Настройках → О доске**: она ставит
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import './js/init.js';
|
|
3
3
|
import { state, api, retryUnsaved } from './js/core.js';
|
|
4
4
|
import { refresh } from './js/sse.js';
|
|
5
|
-
import {
|
|
5
|
+
import { loadProjects, loadTasks, openFolderPicker, renderFolderBrowser, selectProject, styledConfirm, styledPrompt } from './js/sidebar.js';
|
|
6
6
|
import { openDrawer, closeDrawer, deleteTask, cancelTask, setKbCursor } from './js/drawer.js';
|
|
7
7
|
import { openProjectSettings, closeProjectSettings } from './js/project.js';
|
|
8
8
|
import { openSettingsModal, getSetting, setSetting, applyTheme } from './js/settings.js';
|
|
@@ -14,5 +14,6 @@ Object.assign(window, {
|
|
|
14
14
|
openDrawer, closeDrawer, deleteTask, cancelTask, setKbCursor,
|
|
15
15
|
openProjectSettings, closeProjectSettings, openSettingsModal, getSetting, setSetting, applyTheme,
|
|
16
16
|
renderDashboard, styledConfirm, styledPrompt, checkVersion,
|
|
17
|
+
openFolderPicker, renderFolderBrowser,
|
|
17
18
|
retryUnsaved,
|
|
18
19
|
});
|
package/public/js/core.js
CHANGED
|
@@ -83,6 +83,7 @@ const I18N_RU = {
|
|
|
83
83
|
'All projects': 'Все проекты',
|
|
84
84
|
'all self-healed': 'всё починилось само',
|
|
85
85
|
'All set!': 'Готово!',
|
|
86
|
+
'another folder on this computer…': 'другая папка на этом компьютере…',
|
|
86
87
|
'Appearance': 'Оформление',
|
|
87
88
|
'applies after page reload': 'применится после перезагрузки страницы',
|
|
88
89
|
'Archive': 'Архивировать',
|
|
@@ -130,6 +131,7 @@ const I18N_RU = {
|
|
|
130
131
|
'Checking the file and starting the board…': 'Проверяю файл и поднимаю доску…',
|
|
131
132
|
'checking…': 'проверяю…',
|
|
132
133
|
'Chime': 'Перезвон',
|
|
134
|
+
'Choose…': 'Выбрать…',
|
|
133
135
|
'Chord': 'Аккорд',
|
|
134
136
|
'Claude can finish the setup for you — copy this and paste it into its chat:': 'Дальше настройку может провести сам Claude — скопируй это и вставь ему в чат:',
|
|
135
137
|
'Claude Code skills live in skills/ — symlink or copy them into ~/.claude/skills:': 'Скиллы Claude Code лежат в папке skills/ — симлинк или копия в ~/.claude/skills:',
|
|
@@ -208,6 +210,7 @@ const I18N_RU = {
|
|
|
208
210
|
'Delete the task permanently (comments included)?': 'Удалить задачу навсегда (вместе с комментариями)?',
|
|
209
211
|
'Demo project': 'Демо-проект',
|
|
210
212
|
'Deploy skill': 'Deploy-скилл',
|
|
213
|
+
'Deploy skill': 'Деплой-скилл',
|
|
211
214
|
'Deploy skills': 'Деплой-скиллы',
|
|
212
215
|
'Deploy skills of the projects': 'Деплой-скиллы проектов',
|
|
213
216
|
'Deploying': 'Деплою',
|
|
@@ -354,6 +357,7 @@ const I18N_RU = {
|
|
|
354
357
|
'name: latin letters, digits, hyphen': 'имя: латиница, цифры, дефис',
|
|
355
358
|
'name: latin letters, digits, hyphen, underscore only': 'имя: только латиница, цифры, дефис и подчёркивание',
|
|
356
359
|
'needs attention': 'требует внимания',
|
|
360
|
+
'Needs the gh CLI, signed in with the project scope:': 'Нужен gh CLI, авторизованный со scope project:',
|
|
357
361
|
'new': 'новая',
|
|
358
362
|
'new folder': 'новая папка',
|
|
359
363
|
'New project folder': 'Новая папка проекта',
|
|
@@ -370,6 +374,8 @@ const I18N_RU = {
|
|
|
370
374
|
'no backups yet': 'пока нет бэкапов',
|
|
371
375
|
'Claude moves these columns itself': 'эти колонки двигает сам Claude',
|
|
372
376
|
'no file': 'файла нет',
|
|
377
|
+
'no folders inside': 'внутри нет папок',
|
|
378
|
+
'no such folder on this computer': 'такой папки на этом компьютере нет',
|
|
373
379
|
'not deleted': 'не удалено',
|
|
374
380
|
'not installed': 'не установлен',
|
|
375
381
|
'not set': 'не задан',
|
|
@@ -377,11 +383,13 @@ const I18N_RU = {
|
|
|
377
383
|
'not there': 'нет файла',
|
|
378
384
|
'nothing is written to disk until you save': 'на диск ничего не записано, пока не сохранишь',
|
|
379
385
|
'nothing to save': 'нечего сохранять',
|
|
386
|
+
'One repository for the whole board, not one per project. Create it yourself first, and make it private: task titles, descriptions and comments are copied there in full. This is the only thing on this board that leaves your machine.': 'Репозиторий ОДИН на всю доску, а не по одному на проект. Создай его заранее сам и сделай приватным: заголовки, описания и комментарии задач уезжают туда целиком. Это единственное, что покидает твою машину.',
|
|
380
387
|
'Other Claude skills': 'Прочие скиллы Claude',
|
|
381
388
|
'Overwrite': 'Перезаписать',
|
|
382
389
|
'Overwrite this file?': 'Перезаписать этот файл?',
|
|
383
390
|
'own skill': 'свой скилл',
|
|
384
391
|
'Paths & deploy': 'Пути и деплой',
|
|
392
|
+
'Project folder': 'Папка проекта',
|
|
385
393
|
'restarting…': 'перезапускаюсь…',
|
|
386
394
|
'Revert': 'Вернуть как в файле',
|
|
387
395
|
's': 'с',
|
|
@@ -395,17 +403,26 @@ const I18N_RU = {
|
|
|
395
403
|
'Skills': 'Скиллы',
|
|
396
404
|
'Skills are the instructions Claude reads. They live outside the board, in ~/.claude/skills, and the board only shows and updates them.': 'Скиллы — инструкции, по которым работает Claude. Они лежат вне доски, в ~/.claude/skills; доска их только показывает и обновляет.',
|
|
397
405
|
'The board': 'Доска',
|
|
406
|
+
'The board can mirror its tasks to GitHub: every task becomes an issue, and each project gets its own Projects v2 board. Handy when someone else needs to see the work, or when you want the history somewhere besides this machine.': 'Доска умеет зеркалить задачи в GitHub: каждая задача становится issue, а у каждого проекта заводится свой Projects v2. Пригождается, когда работу должен видеть кто-то ещё или когда историю хочется хранить не только на этой машине.',
|
|
398
407
|
'the board has no shared version of this skill — it is your own': 'у доски нет общей версии этого скилла — он ваш собственный',
|
|
399
408
|
'the field above takes a skill name — “deploy” is the shared one': 'в поле выше — имя скилла; «deploy» это общий',
|
|
409
|
+
'The mirror is one-way: the board writes to GitHub and never reads back. Turning it off is emptying these fields.': 'Зеркало одностороннее: доска пишет в GitHub и обратно ничего не читает. Выключить — очистить эти поля.',
|
|
400
410
|
'The project has no local folder — set one above and the notes will show up here.': 'У проекта нет локальной папки — укажи её выше, и заметки появятся здесь.',
|
|
401
411
|
'The project notes and the deploy skill — Claude opens them before touching the code. Paths are absolute: copy one and paste it into the chat.': 'Заметки проекта и деплой-скилл — Claude открывает их до того, как трогать код. Пути полные: скопируй и вставь в чат.',
|
|
402
412
|
'the skill Claude follows to ship this project; “deploy” is the shared one. A new one is created in Settings → Skills.': 'каким скиллом Claude выкатывает этот проект; «deploy» — общий. Свой заводится в «Настройки → Скиллы».',
|
|
403
413
|
'the skill is not installed — the file will be created on save': 'скилл не установлен — файл появится при сохранении',
|
|
404
414
|
'the update did not go through': 'обновиться не удалось',
|
|
415
|
+
'Run it yourself': 'Выполни сам',
|
|
416
|
+
'The same in the README, in more detail': 'То же самое в README, подробнее',
|
|
417
|
+
'the board is not allowed to do this — the command is the same one it just tried': 'у доски нет на это прав — команда та же, что она только что пробовала',
|
|
418
|
+
'Your tasks are not touched: they live in the data directory, apart from the code.': 'Задачи не пострадают: они лежат в папке данных, отдельно от кода.',
|
|
419
|
+
'Afterwards refresh the skills Claude reads:': 'После обновления освежи скиллы, которые читает Claude:',
|
|
420
|
+
'and refresh the skills:': 'и освежи скиллы:',
|
|
405
421
|
'this is a link — only the link is removed, the file it points to stays': 'это ссылка — снимется только она, файл по ту сторону останется',
|
|
406
422
|
'this is a symlink — saving rewrites the file it points to:': 'это ссылка — сохранение перезапишет файл, на который она ведёт:',
|
|
407
423
|
'This project is gone': 'Этого проекта больше нет',
|
|
408
424
|
'this skill was already on disk — added it to the list': 'такой скилл уже лежал на диске — добавил его в список',
|
|
425
|
+
'too many folders to show them all': 'папок слишком много, показаны не все',
|
|
409
426
|
'Update the board now? It will restart.': 'Обновить доску сейчас? Она перезапустится.',
|
|
410
427
|
'update with: npm install -g local-kanban@latest': 'обновить: npm install -g local-kanban@latest',
|
|
411
428
|
Archive: 'Архив',
|
|
@@ -603,6 +620,7 @@ const I18N_RU = {
|
|
|
603
620
|
'updating…': 'обновляю…',
|
|
604
621
|
'Updating…': 'Обновляю…',
|
|
605
622
|
'Upload': 'Загрузить',
|
|
623
|
+
'Use this folder': 'Выбрать эту папку',
|
|
606
624
|
'used by': 'используют',
|
|
607
625
|
'Using the board at work': 'Пользуешься доской в работе',
|
|
608
626
|
'version': 'версия',
|
|
@@ -632,11 +650,13 @@ const I18N_RU = {
|
|
|
632
650
|
'Where do we start?': 'С чего начнём?',
|
|
633
651
|
'Which chaos do we sort out?': 'Какой хаос разгребём?',
|
|
634
652
|
'with the to-do status:': 'со статусом сделать:',
|
|
653
|
+
'Without that scope an issue is still created, but the card never lands on the Projects board.': 'Без этого scope issue создастся, а карточка в Projects так и не появится.',
|
|
635
654
|
'Work time': 'Время в работе',
|
|
636
655
|
'working columns are moved by Claude only': 'рабочие колонки двигает только Claude',
|
|
637
656
|
'Year': 'Год',
|
|
638
657
|
'Years': 'Годы',
|
|
639
658
|
'Yesterday': 'Вчера',
|
|
659
|
+
'your own deploy skill is created and edited in Settings → Skills': 'свой деплой-скилл создаётся и правится в «Настройках → Скиллы»',
|
|
640
660
|
'“Ready to work on” are tasks in the To do column — they can be picked up right now. “In the backlog” are ideas not yet selected for work.': '«Готово к работе» — задачи в колонке «Сделать», их можно брать сейчас. «В бэклоге» — идеи, ещё не отобранные в работу.',
|
|
641
661
|
'‹ Back': '‹ Назад',
|
|
642
662
|
'↑ High': '↑ Высокий',
|
|
@@ -830,7 +850,7 @@ export const api = async (method, path, body, opts = {}) => {
|
|
|
830
850
|
const data = await res.json().catch(() => ({}));
|
|
831
851
|
if (!res.ok) {
|
|
832
852
|
if (!opts.quiet && path !== '/api/errors') reportError(`${method} ${path}`, `HTTP ${res.status}: ${data.error || ''}`.trim());
|
|
833
|
-
throw apiError(data.error || res.status, { status: res.status });
|
|
853
|
+
throw apiError(data.error || res.status, { status: res.status, body: data });
|
|
834
854
|
}
|
|
835
855
|
return data;
|
|
836
856
|
};
|
package/public/js/project.js
CHANGED
|
@@ -124,18 +124,21 @@ export async function renderProjectSettings(force) {
|
|
|
124
124
|
renderDocs(p);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
function
|
|
128
|
-
const host = $('ps-skill');
|
|
129
|
-
if (!host) return;
|
|
130
|
-
const cur = p.deploy_skill || '';
|
|
127
|
+
export function skillOptions(cur, list) {
|
|
131
128
|
const names = list ? list.map((s) => s.name) : (cur ? [cur] : []);
|
|
132
129
|
if (cur && !names.includes(cur)) names.unshift(cur);
|
|
133
130
|
const known = new Set((list || []).map((s) => s.name));
|
|
134
|
-
|
|
131
|
+
return [{ value: '', label: tr('not set — no deploy') }, ...names.map((name) => ({
|
|
135
132
|
value: name,
|
|
136
133
|
label: name === 'deploy' ? `${name} · ${tr('shared')}` : (list && !known.has(name) ? `${name} · ${tr('not installed')}` : name),
|
|
137
134
|
}))];
|
|
138
|
-
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildSkillSelect(p, list) {
|
|
138
|
+
const host = $('ps-skill');
|
|
139
|
+
if (!host) return;
|
|
140
|
+
const cur = p.deploy_skill || '';
|
|
141
|
+
buildSelect(host, { value: cur, options: skillOptions(cur, list) });
|
|
139
142
|
}
|
|
140
143
|
async function loadSkillOptions(p) {
|
|
141
144
|
let info;
|
package/public/js/settings.js
CHANGED
|
@@ -257,6 +257,15 @@ async function renderSection(sec) {
|
|
|
257
257
|
});
|
|
258
258
|
} else if (sec === 'sync') {
|
|
259
259
|
body.innerHTML = `<div class="sm-h">${tr('GitHub sync')}</div>`
|
|
260
|
+
+ `<div class="sync-about">`
|
|
261
|
+
+ `<p>${tr('The board can mirror its tasks to GitHub: every task becomes an issue, and each project gets its own Projects v2 board. Handy when someone else needs to see the work, or when you want the history somewhere besides this machine.')}</p>`
|
|
262
|
+
+ `<p>${tr('One repository for the whole board, not one per project. Create it yourself first, and make it private: task titles, descriptions and comments are copied there in full. This is the only thing on this board that leaves your machine.')}</p>`
|
|
263
|
+
+ `<p>${tr('The mirror is one-way: the board writes to GitHub and never reads back. Turning it off is emptying these fields.')}</p>`
|
|
264
|
+
+ `<p class="muted">${tr('Needs the gh CLI, signed in with the project scope:')}</p>`
|
|
265
|
+
+ '<pre class="onb-code">gh auth login && gh auth refresh -s project</pre>'
|
|
266
|
+
+ `<p class="muted">${tr('Without that scope an issue is still created, but the card never lands on the Projects board.')}</p>`
|
|
267
|
+
+ `<p class="muted"><a href="https://github.com/lamaohub/local-kanban#github-sync-optional" target="_blank" rel="noopener noreferrer">${tr('The same in the README, in more detail')}</a></p>`
|
|
268
|
+
+ `</div>`
|
|
260
269
|
+ `<div class="set-col"><span class="set-lab">${tr('Owner and issues repository')}<small id="sync-cfg-hint">${tr('empty — sync is off, the board runs locally')}</small></span>`
|
|
261
270
|
+ `<div class="sync-cfg-row"><input type="text" id="sync-owner" placeholder="${tr('owner (GitHub user)')}"><input type="text" id="sync-repo" placeholder="${tr('owner/repo for issues')}"><button class="btn-ghost" id="sync-cfg-save">${tr('Save')}</button></div></div>`
|
|
262
271
|
+ `<div class="set-row"><span class="set-lab" id="set-sync">…<small id="set-sync-last"></small></span><button class="btn-ghost" id="set-sync-retry">${tr('Retry failed')}</button></div>`
|
|
@@ -412,6 +421,8 @@ async function renderSection(sec) {
|
|
|
412
421
|
+ `<div class="set-row upd-row"><span class="set-lab"><span id="about-branch-lab">${tr('Board updates')}</span><small id="about-upd-sub">…</small></span>`
|
|
413
422
|
+ `<span class="about-upd-side"><span class="about-fresh" id="about-upd"></span>`
|
|
414
423
|
+ `<button class="btn-ghost hidden" id="about-do-upd">${tr('Install the update')}</button></span></div>`
|
|
424
|
+
+ `<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
|
+
+ '<pre class="onb-code" id="about-upd-cmd"></pre></div>'
|
|
415
426
|
+ `<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>`
|
|
416
427
|
+ `<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>`
|
|
417
428
|
+ `<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>`
|
|
@@ -467,9 +478,19 @@ async function renderSection(sec) {
|
|
|
467
478
|
api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
|
|
468
479
|
.then(() => api('GET', '/api/update-check')).then(renderUpd)
|
|
469
480
|
.catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
|
|
481
|
+
const showFailCmd = (cmd) => {
|
|
482
|
+
const wrap = $('about-upd-fail');
|
|
483
|
+
if (!wrap) return;
|
|
484
|
+
$('about-upd-cmd').textContent = cmd || '';
|
|
485
|
+
wrap.classList.toggle('hidden', !cmd);
|
|
486
|
+
};
|
|
470
487
|
$('about-do-upd').onclick = async () => {
|
|
471
488
|
const btn = $('about-do-upd'); const sub = $('about-upd-sub'); const el = $('about-upd');
|
|
472
|
-
|
|
489
|
+
const ask = [tr('Update the board now? It will restart.'),
|
|
490
|
+
tr('Your tasks are not touched: they live in the data directory, apart from the code.')];
|
|
491
|
+
if (about?.packaged) ask.push(`${tr('Afterwards refresh the skills Claude reads:')} local-kanban skills`);
|
|
492
|
+
if (!(await styledConfirm(ask.join('\n')))) return;
|
|
493
|
+
showFailCmd('');
|
|
473
494
|
const label = btn.textContent;
|
|
474
495
|
let phase = tr('installing…'); let sec = 0;
|
|
475
496
|
const paint = () => { el.textContent = `${phase} ${sec}${tr('s')}`; };
|
|
@@ -483,10 +504,21 @@ async function renderSection(sec) {
|
|
|
483
504
|
btn.textContent = label;
|
|
484
505
|
};
|
|
485
506
|
let r;
|
|
486
|
-
try {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
507
|
+
try {
|
|
508
|
+
r = await api('POST', '/api/update', null, { quiet: true });
|
|
509
|
+
} catch (err) {
|
|
510
|
+
stop(tr('Did not work out'), true);
|
|
511
|
+
sub.textContent = `${tr('the update did not go through')}: ${err.message || err}`;
|
|
512
|
+
showFailCmd(err.body?.cmd);
|
|
513
|
+
btn.disabled = false; return;
|
|
514
|
+
}
|
|
515
|
+
if (!r?.ok) { stop(tr('Did not work out'), true); sub.textContent = `${tr('the update did not go through')}: ${(r?.output || '').split('\n').pop() || '—'}`; showFailCmd(r?.cmd); btn.disabled = false; return; }
|
|
516
|
+
if (r.restart !== 'pm2') {
|
|
517
|
+
stop('', false);
|
|
518
|
+
sub.textContent = tr('updated — start the board again to pick it up')
|
|
519
|
+
+ (about?.packaged ? ` · ${tr('and refresh the skills:')} local-kanban skills` : '');
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
490
522
|
phase = tr('restarting…'); sec = 0; paint();
|
|
491
523
|
for (let i = 0; i < 60; i++) {
|
|
492
524
|
await new Promise((ok) => setTimeout(ok, 1000));
|
package/public/js/sidebar.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
|
|
2
|
-
import { renderBoard } from './board.js';
|
|
2
|
+
import { buildSelect, renderBoard, selVal } from './board.js';
|
|
3
|
+
|
|
4
|
+
const GENERIC_DEPLOY = 'deploy';
|
|
3
5
|
import { renderChaos } from './chaos.js';
|
|
4
6
|
import { $, ALL, CALENDAR, CHAOS, DASH, HIDDEN_SECTIONS, HORIZON, LANG, SETTINGS, SIDEBAR_SECTIONS, api, esc, ic, seg, setupPrompt, state, tr } from './core.js';
|
|
5
7
|
import { renderDashboard } from './dash.js';
|
|
6
8
|
import { renderCalendar, renderHorizon } from './horizon.js';
|
|
7
|
-
import { openProjectSettings, renderProjectSettings } from './project.js';
|
|
9
|
+
import { openProjectSettings, renderProjectSettings, skillOptions } from './project.js';
|
|
8
10
|
import { openSettingsModal, plural, setSetting, syncLangToServer } from './settings.js';
|
|
9
11
|
import { copyText, refresh, showView } from './sse.js';
|
|
10
12
|
|
|
@@ -344,6 +346,37 @@ function wizSlugify(name) {
|
|
|
344
346
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
345
347
|
}
|
|
346
348
|
|
|
349
|
+
export async function openFolderPicker() {
|
|
350
|
+
const box = $('wiz-browser'); const input = $('wiz-path');
|
|
351
|
+
if (!box || !input) return;
|
|
352
|
+
try {
|
|
353
|
+
const r = await api('POST', '/api/fs/pick', null, { quiet: true });
|
|
354
|
+
if (r?.path) { input.value = r.path; box.classList.add('hidden'); return; }
|
|
355
|
+
if (r?.cancelled) return;
|
|
356
|
+
} catch { }
|
|
357
|
+
await renderFolderBrowser(input.value.trim() || undefined);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function renderFolderBrowser(path) {
|
|
361
|
+
const box = $('wiz-browser');
|
|
362
|
+
if (!box) return;
|
|
363
|
+
let d;
|
|
364
|
+
const ask = (p) => api('GET', p ? `/api/fs?path=${encodeURIComponent(p)}` : '/api/fs', null, { quiet: true });
|
|
365
|
+
try { d = await ask(path); } catch { try { d = await ask(); } catch { return; } }
|
|
366
|
+
const child = (n) => (d.path.endsWith('/') ? d.path + n : `${d.path}/${n}`);
|
|
367
|
+
box.classList.remove('hidden');
|
|
368
|
+
box.innerHTML = `<div class="wizb-path">${esc(d.path)}</div>`
|
|
369
|
+
+ '<div class="wizb-list">'
|
|
370
|
+
+ (d.parent ? `<div class="wizb-row wizb-up" data-p="${esc(d.parent)}">..</div>` : '')
|
|
371
|
+
+ d.dirs.map((n) => `<div class="wizb-row" data-p="${esc(child(n))}">${esc(n)}</div>`).join('')
|
|
372
|
+
+ (d.dirs.length ? '' : `<div class="wizb-note">${tr('no folders inside')}</div>`)
|
|
373
|
+
+ (d.truncated ? `<div class="wizb-note">${tr('too many folders to show them all')}</div>` : '')
|
|
374
|
+
+ '</div>'
|
|
375
|
+
+ `<button type="button" class="btn-primary wizb-pick">${tr('Use this folder')}</button>`;
|
|
376
|
+
box.querySelectorAll('.wizb-row').forEach((r) => { r.onclick = () => renderFolderBrowser(r.dataset.p); });
|
|
377
|
+
box.querySelector('.wizb-pick').onclick = () => { $('wiz-path').value = d.path; box.classList.add('hidden'); };
|
|
378
|
+
}
|
|
379
|
+
|
|
347
380
|
function openProjectWizard() {
|
|
348
381
|
document.getElementById('proj-wizard')?.remove();
|
|
349
382
|
const overlay = document.createElement('div');
|
|
@@ -380,14 +413,13 @@ function openProjectWizard() {
|
|
|
380
413
|
let html = `<label class="pp-field">${tr('Project name')}<input id="wiz-name" type="text" placeholder="my project"></label>`
|
|
381
414
|
+ `<label class="pp-field">${tr('Key (slug + task prefix)')}<input id="wiz-slug" type="text" placeholder="my-project"></label>`;
|
|
382
415
|
if (withLocal) {
|
|
383
|
-
html += `<div class="pp-field">${tr('Project
|
|
384
|
-
<
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
<input id="wiz-path" type="text" class="hidden" placeholder="/full/path/to/the/folder">
|
|
416
|
+
html += `<div class="pp-field">${tr('Project folder')}
|
|
417
|
+
<div id="wiz-folder"></div>
|
|
418
|
+
<div id="wiz-path-row" class="wiz-path-row hidden">
|
|
419
|
+
<input id="wiz-path" type="text" placeholder="/full/path/to/the/folder">
|
|
420
|
+
<button type="button" class="btn-ghost" id="wiz-browse">${tr('Choose…')}</button>
|
|
421
|
+
</div>
|
|
422
|
+
<div id="wiz-browser" class="wiz-browser hidden"></div>
|
|
391
423
|
<input id="wiz-git" type="text" class="hidden" placeholder="git@github.com:me/repo.git or https://…"></div>`;
|
|
392
424
|
}
|
|
393
425
|
if (withServer) {
|
|
@@ -395,7 +427,8 @@ function openProjectWizard() {
|
|
|
395
427
|
+ `<label class="pp-field">${tr('Server path')}<input id="wiz-spath" type="text" placeholder="/var/www/my-project"></label>`
|
|
396
428
|
+ `<label class="pp-field">${tr('pm2 processes (comma-separated)')}<input id="wiz-pm2" type="text" placeholder="my-api, my-web"></label>`
|
|
397
429
|
+ `<label class="pp-field">${tr('Domain (to verify after deploy)')}<input id="wiz-domain" type="text" placeholder="example.com"></label>`
|
|
398
|
-
+ `<
|
|
430
|
+
+ `<div class="pp-field">${tr('Deploy skill')}<div id="wiz-skill"></div>`
|
|
431
|
+
+ `<small class="pp-hint">${tr('your own deploy skill is created and edited in Settings → Skills')}</small></div>`;
|
|
399
432
|
}
|
|
400
433
|
body.innerHTML = html;
|
|
401
434
|
actions.innerHTML = `<button class="btn-primary wiz-create">${tr('Create')}</button><button class="btn-ghost wiz-back">${tr('‹ Back')}</button>`;
|
|
@@ -407,10 +440,34 @@ function openProjectWizard() {
|
|
|
407
440
|
nameEl.oninput = () => { if (!slugTouched) slugEl.value = wizSlugify(nameEl.value); };
|
|
408
441
|
const folderSel = $('wiz-folder');
|
|
409
442
|
if (folderSel) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
443
|
+
const options = [
|
|
444
|
+
...(type === 'local' ? [{ value: '', label: tr('no folder — just a task list') }] : []),
|
|
445
|
+
...folders.map((f) => ({ value: f, label: `${tr('folder')} ${f} (${state.folders.root || '~'})` })),
|
|
446
|
+
{ value: '__manual__', label: tr('another folder on this computer…') },
|
|
447
|
+
...(type === 'localserver' ? [{ value: '__clone__', label: tr('clone from a git URL…') }] : []),
|
|
448
|
+
];
|
|
449
|
+
const onFolderChange = (v) => {
|
|
450
|
+
$('wiz-path-row').classList.toggle('hidden', v !== '__manual__');
|
|
451
|
+
$('wiz-browser').classList.add('hidden');
|
|
452
|
+
$('wiz-git').classList.toggle('hidden', v !== '__clone__');
|
|
453
|
+
if (v === '__manual__') openFolderPicker();
|
|
413
454
|
};
|
|
455
|
+
buildSelect(folderSel, { value: type === 'local' ? '' : (options[0]?.value ?? ''), options, onChange: onFolderChange });
|
|
456
|
+
$('wiz-browse').onclick = openFolderPicker;
|
|
457
|
+
}
|
|
458
|
+
const skillHost = $('wiz-skill');
|
|
459
|
+
if (skillHost) {
|
|
460
|
+
buildSelect(skillHost, { value: GENERIC_DEPLOY, options: skillOptions(GENERIC_DEPLOY, null) });
|
|
461
|
+
api('GET', '/api/skills', null, { quiet: true })
|
|
462
|
+
.then((info) => {
|
|
463
|
+
if (!$('wiz-skill')) return;
|
|
464
|
+
const generic = info.generic_deploy_skill || GENERIC_DEPLOY;
|
|
465
|
+
const own = new Set(info.own_skills || []);
|
|
466
|
+
const deployish = (info.items || []).filter((s) => s.name === generic || s.used_by?.length || own.has(s.name));
|
|
467
|
+
const has = deployish.some((s) => s.name === generic);
|
|
468
|
+
buildSelect($('wiz-skill'), { value: has ? generic : '', options: skillOptions(generic, deployish) });
|
|
469
|
+
})
|
|
470
|
+
.catch(() => { });
|
|
414
471
|
}
|
|
415
472
|
actions.querySelector('.wiz-create').onclick = () => wizCreate(type).catch((e) => err(e.message));
|
|
416
473
|
nameEl.focus();
|
|
@@ -428,10 +485,12 @@ function openProjectWizard() {
|
|
|
428
485
|
btn.disabled = true;
|
|
429
486
|
try {
|
|
430
487
|
if (type !== 'server') {
|
|
431
|
-
const mode = $('wiz-folder')
|
|
488
|
+
const mode = $('wiz-folder') ? selVal($('wiz-folder')) : '';
|
|
432
489
|
if (mode === '__manual__') {
|
|
433
490
|
const p = $('wiz-path').value.trim();
|
|
434
491
|
if (!p) throw new Error(tr('give the folder path'));
|
|
492
|
+
try { await api('GET', `/api/fs?path=${encodeURIComponent(p)}`, null, { quiet: true }); }
|
|
493
|
+
catch { throw new Error(`${tr('no such folder on this computer')}: ${p}`); }
|
|
435
494
|
body.path = p;
|
|
436
495
|
} else if (mode === '__clone__') {
|
|
437
496
|
const url = $('wiz-git').value.trim();
|
|
@@ -454,7 +513,8 @@ function openProjectWizard() {
|
|
|
454
513
|
if (pm2.length) body.pm2_services = JSON.stringify(pm2);
|
|
455
514
|
const domain = $('wiz-domain').value.trim();
|
|
456
515
|
if (domain) body.domain = domain;
|
|
457
|
-
|
|
516
|
+
const skill = selVal($('wiz-skill'));
|
|
517
|
+
if (skill) body.deploy_skill = skill;
|
|
458
518
|
}
|
|
459
519
|
if (type === 'server') {
|
|
460
520
|
btn.textContent = tr('checking git on the server…');
|
package/public/style.css
CHANGED
|
@@ -141,6 +141,23 @@ body {
|
|
|
141
141
|
}
|
|
142
142
|
.btn-ghost:hover { color: var(--text); }
|
|
143
143
|
a.btn-ghost { text-decoration: none; }
|
|
144
|
+
.sync-about { font-size: 12.5px; line-height: 1.5; color: var(--text); display: flex; flex-direction: column; gap: 7px; margin-bottom: 4px; }
|
|
145
|
+
.sync-about p { margin: 0; }
|
|
146
|
+
.sync-about .muted, .sync-about p.muted { color: var(--muted); }
|
|
147
|
+
.sync-about code { font-size: 11.5px; background: var(--chip-bg); padding: 1px 5px; border-radius: 5px; }
|
|
148
|
+
.pp-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 11.5px; }
|
|
149
|
+
.wiz-path-row { display: flex; gap: 8px; align-items: center; }
|
|
150
|
+
.wiz-path-row input { flex: 1; min-width: 0; }
|
|
151
|
+
.wiz-browser {
|
|
152
|
+
margin-top: 8px; border: 1px solid var(--border); border-radius: 8px;
|
|
153
|
+
padding: 8px; display: flex; flex-direction: column; gap: 8px;
|
|
154
|
+
}
|
|
155
|
+
.wizb-path { font-size: 11.5px; color: var(--muted); overflow-wrap: anywhere; }
|
|
156
|
+
.wizb-list { max-height: 190px; overflow-y: auto; display: flex; flex-direction: column; }
|
|
157
|
+
.wizb-row { padding: 5px 7px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
158
|
+
.wizb-row:hover { background: var(--chip-bg); }
|
|
159
|
+
.wizb-up { color: var(--muted); }
|
|
160
|
+
.wizb-note { padding: 5px 7px; font-size: 11.5px; color: var(--muted); }
|
|
144
161
|
.about-upd-side { display: flex; align-items: center; gap: 10px; justify-content: flex-end; flex-shrink: 0; }
|
|
145
162
|
.set-row.upd-row { flex-wrap: wrap; }
|
|
146
163
|
.set-row.upd-row .set-lab { flex: 1 1 200px; min-width: 0; }
|
package/src/routes/projects.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { join, basename, resolve, sep } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
import { db, DATA_DIR, genPrefix, usedPrefixes, makePrefix, kvGet, kvSet, logError } from '../db.js';
|
|
5
6
|
import { localRoot, panelUrl, panelInfo } from '../config.js';
|
|
6
7
|
import { emit } from '../bus.js';
|
|
@@ -223,6 +224,47 @@ export default async function projectRoutes(app) {
|
|
|
223
224
|
|
|
224
225
|
app.get('/api/projects/folders', () => scanFolders());
|
|
225
226
|
|
|
227
|
+
const FS_LIMIT = 500;
|
|
228
|
+
app.get('/api/fs', (req, reply) => {
|
|
229
|
+
const raw = req.query?.path;
|
|
230
|
+
if (raw !== undefined && typeof raw !== 'string') return reply.code(400).send({ error: 'path: expected a string' });
|
|
231
|
+
const dir = raw ? resolve(String(raw).replace(/^~(?=$|\/)/, homedir())) : localRoot();
|
|
232
|
+
let st;
|
|
233
|
+
try { st = statSync(dir); } catch { return reply.code(404).send({ error: 'no such folder' }); }
|
|
234
|
+
if (!st.isDirectory()) return reply.code(400).send({ error: 'not a folder' });
|
|
235
|
+
let names;
|
|
236
|
+
try { names = readdirSync(dir, { withFileTypes: true }); }
|
|
237
|
+
catch (e) { return reply.code(403).send({ error: `cannot read the folder: ${e.code || e.message}` }); }
|
|
238
|
+
const dirs = names.filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name).sort();
|
|
239
|
+
const parent = resolve(dir, '..');
|
|
240
|
+
return {
|
|
241
|
+
path: dir,
|
|
242
|
+
parent: parent === dir ? null : parent,
|
|
243
|
+
dirs: dirs.slice(0, FS_LIMIT),
|
|
244
|
+
truncated: dirs.length > FS_LIMIT,
|
|
245
|
+
home: homedir(),
|
|
246
|
+
root: localRoot(),
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const PICKERS = {
|
|
251
|
+
darwin: ['osascript', ['-e', 'tell application "System Events" to activate', '-e', 'POSIX path of (choose folder with prompt "Choose the project folder")']],
|
|
252
|
+
linux: ['zenity', ['--file-selection', '--directory', '--title=Choose the project folder']],
|
|
253
|
+
};
|
|
254
|
+
app.post('/api/fs/pick', async (req, reply) => {
|
|
255
|
+
const spec = PICKERS[process.platform];
|
|
256
|
+
if (!spec) return reply.code(501).send({ error: `no folder dialog on ${process.platform}` });
|
|
257
|
+
const [cmd, args] = spec;
|
|
258
|
+
const res = await new Promise((done) => {
|
|
259
|
+
execFile(cmd, args, { timeout: 180000 }, (err, out, errOut) => done({ err, out: String(out || '').trim(), errOut: String(errOut || '') }));
|
|
260
|
+
});
|
|
261
|
+
if (res.err) {
|
|
262
|
+
if (!res.out) return { path: null, cancelled: true };
|
|
263
|
+
return reply.code(500).send({ error: res.errOut.trim() || res.err.message });
|
|
264
|
+
}
|
|
265
|
+
return { path: res.out || null, cancelled: !res.out };
|
|
266
|
+
});
|
|
267
|
+
|
|
226
268
|
app.post('/api/projects/folders', (req, reply) => {
|
|
227
269
|
const raw = req.body?.name;
|
|
228
270
|
if (raw !== undefined && raw !== null && typeof raw !== 'string' && typeof raw !== 'number') {
|
package/src/routes/system.js
CHANGED
|
@@ -53,10 +53,13 @@ export function globalPrefixOf(root, name) {
|
|
|
53
53
|
return prefix ? (root.startsWith(sep) ? sep + prefix : prefix) : null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const NPM_NOISE = /^npm error(\s+at\b|\s*[{}]|\s+A complete log of this run\b|\s+(errno|syscall|path|dest|stack|code|signal|cmd|args|file):)/i;
|
|
56
57
|
export function failureReason(output, fallback = '') {
|
|
57
58
|
const lines = String(output || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
58
59
|
const marked = lines.filter((l) => /^(error\b|fatal:|npm error)/i.test(l));
|
|
59
|
-
const
|
|
60
|
+
const pool = marked.filter((l) => !NPM_NOISE.test(l));
|
|
61
|
+
const best = (pool.length ? pool : marked);
|
|
62
|
+
const pick = best.length ? best.reduce((a, b) => (b.length > a.length ? b : a)) : lines.at(-1);
|
|
60
63
|
return (pick || fallback || '').slice(0, 300);
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -315,7 +318,11 @@ export default async function systemRoutes(app) {
|
|
|
315
318
|
const output = res.text.slice(-2000);
|
|
316
319
|
if (res.err) {
|
|
317
320
|
logError('server', 'update', `update via ${plan.how} failed: ${res.err.message}`, output);
|
|
318
|
-
|
|
321
|
+
|
|
322
|
+
return reply.code(500).send({
|
|
323
|
+
ok: false, how: plan.how, output, cmd: [plan.cmd, ...plan.args].join(' '),
|
|
324
|
+
error: failureReason(output, res.err.message),
|
|
325
|
+
});
|
|
319
326
|
}
|
|
320
327
|
const restart = restartMode();
|
|
321
328
|
if (restart === 'pm2') setTimeout(() => process.exit(0), 400).unref();
|