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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
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 { selectProject, loadProjects, loadTasks, styledConfirm, styledPrompt } from './js/sidebar.js';
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
  };
@@ -124,18 +124,21 @@ export async function renderProjectSettings(force) {
124
124
  renderDocs(p);
125
125
  }
126
126
 
127
- function buildSkillSelect(p, list) {
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
- const options = [{ value: '', label: tr('not set — no deploy') }, ...names.map((name) => ({
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
- buildSelect(host, { value: cur, options });
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;
@@ -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 &amp;&amp; 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
- if (!(await styledConfirm(tr('Update the board now? It will restart.')))) return;
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 { r = await api('POST', '/api/update', null, { quiet: true }); }
487
- catch (err) { stop(tr('Did not work out'), true); sub.textContent = `${tr('the update did not go through')}: ${err.message || err}`; btn.disabled = false; return; }
488
- if (!r?.ok) { stop(tr('Did not work out'), true); sub.textContent = `${tr('the update did not go through')}: ${(r?.output || '').split('\n').pop() || '—'}`; btn.disabled = false; return; }
489
- if (r.restart !== 'pm2') { stop('', false); sub.textContent = tr('updated — start the board again to pick it up'); return; }
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));
@@ -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 key')}
384
- <select id="wiz-folder" class="wiz-select">
385
- ${type === 'local' ? `<option value="">${tr('no folder — just a task list')}</option>` : ''}
386
- ${folders.map((f) => `<option value="${esc(f)}">${tr('folder')} ${esc(f)} (${esc(state.folders.root || '~')})</option>`).join('')}
387
- <option value="__manual__">${tr('enter the folder path…')}</option>
388
- ${type === 'localserver' ? `<option value="__clone__">${tr('clone from a git URL…')}</option>` : ''}
389
- </select>
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
- + `<label class="pp-field">${tr('Custom deploy skill')}<input id="wiz-skill" type="text"></label>`;
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
- folderSel.onchange = () => {
411
- $('wiz-path').classList.toggle('hidden', folderSel.value !== '__manual__');
412
- $('wiz-git').classList.toggle('hidden', folderSel.value !== '__clone__');
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')?.value ?? '';
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
- body.deploy_skill = $('wiz-skill').value.trim() || 'deploy';
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; }
@@ -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') {
@@ -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 pick = marked.length ? marked.reduce((a, b) => (b.length > a.length ? b : a)) : lines.at(-1);
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
- return reply.code(500).send({ ok: false, how: plan.how, output, error: failureReason(output, res.err.message) });
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();