local-kanban 1.11.0 → 1.12.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,10 +3,40 @@
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.12.0]
7
+
8
+ ### Added
9
+
10
+ - **The wizard answers before you press Create.** Three checks with one result line each: the ssh host
11
+ ("Check" runs a real connection, and says *why* it failed — unknown host, refused key, unknown host
12
+ key, no answer — rather than handing you ssh's stderr), the project folder, and the project key,
13
+ whose availability now shows while you type instead of arriving as a refusal after Create. The key
14
+ line also shows the task prefix you are going to get, which until now you first saw on your first
15
+ task and could never change.
16
+
17
+ - **A third way to add a project: let Claude do it.** Half of the form (ssh host, server path, pm2,
18
+ deploy skill) needs you to go and look something up, and Claude is right there and can look. The
19
+ first step now offers a ready prompt to copy: it makes Claude inspect the folder, verify the ssh
20
+ access itself, ask you one question at a time, and register the project when you have both agreed.
21
+
22
+ - **The git clone mode says where the repository lands.** It was called "clone from a git URL…" and
23
+ said nothing else. It now shows the actual destination path on this computer, and the two facts you
24
+ could not have guessed: an existing folder is refused rather than overwritten, and git credentials
25
+ are never asked for here.
26
+
6
27
  ## [1.11.0]
7
28
 
8
29
  ### Fixed
9
30
 
31
+ - **Where the SSH key or password goes: nowhere, and that is now said out loud.** The fields told
32
+ you access "comes from your `~/.ssh/config`", which answers where it comes from and not what you
33
+ are supposed to do. The board holds no credentials at all: it runs `ssh <host>`, and a password
34
+ cannot work even in principle, because it connects without prompts — key-based access is required.
35
+ The short test is in the hint now: if `ssh <host>` works in your terminal, it works here. The same
36
+ paragraph went into the security section of both READMEs, which said nothing about ssh until now.
37
+ The server fields also got a heading of their own, and the host field shows an address as an
38
+ example rather than a description of one.
39
+
10
40
  - **The server fields say which side they are about.** "SSH host" and "Server path" gave no hint of
11
41
  whether they meant this computer or the machine you deploy to — a fair question, and the owner of
12
42
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
package/public/js/core.js CHANGED
@@ -413,8 +413,30 @@ const I18N_RU = {
413
413
  'the skill is not installed — the file will be created on save': 'скилл не установлен — файл появится при сохранении',
414
414
  'the update did not go through': 'обновиться не удалось',
415
415
  'Run it yourself': 'Выполни сам',
416
- 'Below is about the SERVER this project is deployed to, not about this computer. Empty means the project stays local.': 'Ниже — про СЕРВЕР, на который выкатывается проект, а не про этот компьютер. Пусто — проект остаётся локальным.',
417
- 'user, port and key come from your ~/.ssh/config — the board keeps no secrets': 'пользователь, порт и ключ берутся из твоего ~/.ssh/config — секретов доска не хранит',
416
+ 'Server': 'Сервер',
417
+ 'Check': 'Проверить',
418
+ 'checking…': 'проверяю…',
419
+ 'connecting…': 'подключаюсь…',
420
+ 'connected': 'подключились',
421
+ 'the server path is there': 'путь на сервере есть',
422
+ 'but there is no such path on the server': 'но такого пути на сервере нет',
423
+ 'give the ssh host': 'укажи ssh-хост',
424
+ 'the folder is there': 'папка на месте',
425
+ 'subfolders': 'подпапок',
426
+ 'key is free': 'ключ свободен',
427
+ 'task prefix': 'префикс задач',
428
+ 'this key is already taken by': 'этот ключ уже занят проектом',
429
+ 'no such host — check the name, or add it to your ~/.ssh/config': 'такого хоста нет — проверь имя или добавь его в свой ~/.ssh/config',
430
+ 'the server refused the key — a password will not help here, the key has to be authorized': 'сервер не принял ключ — пароль тут не поможет, ключ надо разрешить на сервере',
431
+ 'the host key is unknown — connect once from your terminal and confirm the fingerprint': 'ключ хоста неизвестен — подключись разок из терминала и подтверди отпечаток',
432
+ 'the server did not answer — wrong address, wrong port, or it is down': 'сервер не ответил — не тот адрес, не тот порт или он лежит',
433
+ 'could not connect': 'подключиться не вышло',
434
+ 'on create, git clone puts it on THIS computer at': 'при создании git clone положит его на ЭТОТ компьютер в',
435
+ '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-ключей.',
436
+ 'or let Claude do it': 'или пусть настроит Claude',
437
+ '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.',
438
+ 'Not about this computer. Empty means the project simply stays local.': 'Не про этот компьютер. Пусто — проект просто останется локальным.',
439
+ '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 &lt;host&gt; works in your terminal, it works here.': 'ключ и пароль здесь не вводятся: доступ берётся из твоего ~/.ssh/config, и пароль не подойдёт — нужен ключ. Работает ssh &lt;хост&gt; в терминале — заработает и здесь.',
418
440
  '“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
441
  'the board runs ssh &lt;host&gt; — user, port and key come from your ~/.ssh/config, no secrets are kept here': 'доска выполняет ssh &lt;хост&gt; — пользователь, порт и ключ берутся из твоего ~/.ssh/config, секретов доска не хранит',
420
442
  'where the project lies ON THE SERVER — the deploy pulls into this folder': 'где проект лежит НА СЕРВЕРЕ — именно в эту папку деплой делает git pull',
@@ -799,6 +821,30 @@ Do not touch my files or servers without asking.`,
799
821
  };
800
822
  export const setupPrompt = () => SETUP_PROMPT[LANG] || SETUP_PROMPT.ru;
801
823
 
824
+ export const ADD_PROJECT_PROMPT = {
825
+ ru: `Заведи мне проект на канбан-доске (local-kanban). Спрашивай по одному вопросу и жди ответа — ничего не додумывай за меня.
826
+
827
+ 1. Спроси, что за проект и где на этом компьютере лежит его код. Проверь, что папка существует, и загляни в неё: посмотри package.json или другой манифест, есть ли git и куда он смотрит.
828
+ 2. Если проект куда-то выкатывается — спроси ssh-хост и путь на сервере. ПРОВЕРЬ доступ сам: ssh -o BatchMode=yes <хост> "test -d <путь> && echo ok". Не сработало — скажи мне, в чём дело, и не выдумывай значения.
829
+ 3. Спроси, надо ли перезапускать что-то после выкатки (имена процессов pm2) и по какому домену проверять, что всё поднялось.
830
+ 4. Реши, каким деплой-скиллом его выкатывать: посмотри kb p и деплой-скиллы соседних проектов, предложи подходящий или «без деплоя». Свой скилл заводится в «Настройках → Скиллы».
831
+ 5. Заведи проект через API доски (POST /api/projects) со всем, что выяснили, и покажи мне kb info <ключ>.
832
+ 6. Если у проекта есть CLAUDE.md или README — прочитай и предложи 3-5 первых задач в бэклог, но НЕ создавай их без моего «да».
833
+
834
+ Ничего не меняй в самом проекте и на сервере: эта задача — только про запись в реестр доски.`,
835
+ 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.
836
+
837
+ 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.
838
+ 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.
839
+ 3. Ask whether anything needs restarting after a deploy (pm2 process names) and which domain to check afterwards.
840
+ 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.
841
+ 5. Register the project through the board API (POST /api/projects) with everything we worked out, and show me kb info <key>.
842
+ 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.
843
+
844
+ Change nothing in the project itself and nothing on the server: this task is only about the board's registry.`,
845
+ };
846
+ export const addProjectPrompt = () => ADD_PROJECT_PROMPT[LANG] || ADD_PROJECT_PROMPT.en;
847
+
802
848
  export const seg = (s) => encodeURIComponent(String(s));
803
849
  export let ghSyncOn = true;
804
850
  export function setGhSyncOn(v) { ghSyncOn = v; }
@@ -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="${tr('ssh alias or address')}">
75
- <small class="ps-hint">${tr('the board runs ssh &lt;host&gt; — user, port and key come from your ~/.ssh/config, no secrets are kept here')}</small></label>
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 &lt;host&gt; 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">
@@ -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,63 @@ 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
+ setState('wiz-path-state', `${tr('no such folder on this computer')}: ${e.message || path}`, 'bad');
378
+ return false;
379
+ }
380
+ }
381
+
382
+ const SSH_WHY = {
383
+ unknown_host: 'no such host — check the name, or add it to your ~/.ssh/config',
384
+ key_refused: 'the server refused the key — a password will not help here, the key has to be authorized',
385
+ host_key: 'the host key is unknown — connect once from your terminal and confirm the fingerprint',
386
+ unreachable: 'the server did not answer — wrong address, wrong port, or it is down',
387
+ failed: 'could not connect',
388
+ };
389
+ export async function checkSsh() {
390
+ const server = $('wiz-server')?.value.trim();
391
+ const path = $('wiz-spath')?.value.trim();
392
+ if (!server) { setState('wiz-ssh-state', tr('give the ssh host'), 'bad'); return; }
393
+ const btn = $('wiz-ssh-check');
394
+ if (btn) btn.disabled = true;
395
+ setState('wiz-ssh-state', tr('connecting…'));
396
+ try {
397
+ const r = await api('POST', '/api/projects/ssh-check', { server, path }, { quiet: true });
398
+ 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')}`;
399
+ setState('wiz-ssh-state', `${tr('connected')}${dir}`, r.path_exists === false ? 'bad' : 'ok');
400
+ } catch (e) {
401
+ setState('wiz-ssh-state', tr(SSH_WHY[e.body?.reason] || SSH_WHY.failed), 'bad');
402
+ }
403
+ if (btn) btn.disabled = false;
404
+ }
405
+
349
406
  export async function openFolderPicker() {
350
407
  const box = $('wiz-browser'); const input = $('wiz-path');
351
408
  if (!box || !input) return;
@@ -399,10 +456,21 @@ function openProjectWizard() {
399
456
  const stepType = () => {
400
457
  err('');
401
458
  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('');
459
+ `<button type="button" class="wiz-type" data-type="${id}"><b>${esc(title)}</b><small>${esc(sub)}</small></button>`).join('')
460
+ + `<div class="wiz-h">${tr('or let Claude do it')}</div>`
461
+ + `<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>`
462
+ + `<div class="setup-prompt"><pre class="onb-code setup-prompt-text">${esc(addProjectPrompt())}</pre>`
463
+ + `<button type="button" class="btn-ghost wiz-ai-copy">${ic('copy', 13)} ${tr('Copy the prompt')}</button></div>`;
403
464
  actions.innerHTML = `<button class="btn-ghost wiz-cancel">${tr('Cancel')}</button>`;
404
465
  actions.querySelector('.wiz-cancel').onclick = close;
405
466
  body.querySelectorAll('.wiz-type').forEach((b) => { b.onclick = () => stepForm(b.dataset.type); });
467
+ const aiBtn = body.querySelector('.wiz-ai-copy');
468
+ aiBtn.onclick = async () => {
469
+ const okCopy = await copyText(addProjectPrompt());
470
+ const was = aiBtn.innerHTML;
471
+ aiBtn.innerHTML = okCopy ? `✓ ${tr('Copied')}` : `✕ ${tr('did not work — select and copy manually')}`;
472
+ setTimeout(() => { aiBtn.innerHTML = was; }, 2000);
473
+ };
406
474
  };
407
475
 
408
476
  const stepForm = (type) => {
@@ -411,21 +479,28 @@ function openProjectWizard() {
411
479
  const withLocal = type !== 'server';
412
480
  const folders = state.folders.unregistered || [];
413
481
  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"></label>`;
482
+ + `<label class="pp-field">${tr('Key (slug + task prefix)')}<input id="wiz-slug" type="text" placeholder="my-project">`
483
+ + `<small class="pp-hint" id="wiz-slug-state"></small></label>`;
415
484
  if (withLocal) {
416
485
  html += `<div class="pp-field">${tr('Project folder')}
417
486
  <div id="wiz-folder"></div>
418
487
  <div id="wiz-path-row" class="wiz-path-row hidden">
419
488
  <input id="wiz-path" type="text" placeholder="/full/path/to/the/folder">
420
489
  <button type="button" class="btn-ghost" id="wiz-browse">${tr('Choose…')}</button>
490
+ <button type="button" class="btn-ghost" id="wiz-path-check">${tr('Check')}</button>
421
491
  </div>
492
+ <small class="pp-hint" id="wiz-path-state"></small>
422
493
  <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"></div>`;
494
+ <input id="wiz-git" type="text" class="hidden" placeholder="git@github.com:me/repo.git">
495
+ <small class="pp-hint hidden" id="wiz-git-hint"></small></div>`;
424
496
  }
425
497
  if (withServer) {
426
- html += `<div class="kbh-note muted">${tr('Below is about the SERVER this project is deployed to, not about this computer. Empty means the project stays local.')}</div>`
427
- + `<label class="pp-field">${tr('SSH host')}<input id="wiz-server" type="text" placeholder="${tr('ssh alias or address')}">`
428
- + `<small class="pp-hint">${tr('user, port and key come from your ~/.ssh/config — the board keeps no secrets')}</small></label>`
498
+ html += `<div class="wiz-h">${tr('Server')}</div>`
499
+ + `<div class="kbh-note muted">${tr('Not about this computer. Empty means the project simply stays local.')}</div>`
500
+ + `<div class="pp-field">${tr('SSH host')}`
501
+ + `<div class="wiz-path-row"><input id="wiz-server" type="text" placeholder="192.168.1.10">`
502
+ + `<button type="button" class="btn-ghost" id="wiz-ssh-check">${tr('Check')}</button></div>`
503
+ + `<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 &lt;host&gt; works in your terminal, it works here.')}</small></div>`
429
504
  + `<label class="pp-field">${tr('Server path')}<input id="wiz-spath" type="text" placeholder="/var/www/my-project"></label>`
430
505
  + `<label class="pp-field">${tr('pm2 processes (comma-separated)')}<input id="wiz-pm2" type="text" placeholder="my-api, my-web"></label>`
431
506
  + `<label class="pp-field">${tr('Domain (to verify after deploy)')}<input id="wiz-domain" type="text" placeholder="example.com"></label>`
@@ -438,8 +513,19 @@ function openProjectWizard() {
438
513
  const nameEl = $('wiz-name');
439
514
  const slugEl = $('wiz-slug');
440
515
  let slugTouched = false;
441
- slugEl.oninput = () => { slugTouched = true; };
442
- nameEl.oninput = () => { if (!slugTouched) slugEl.value = wizSlugify(nameEl.value); };
516
+ const showSlugState = () => {
517
+ const el = $('wiz-slug-state');
518
+ if (!el) return;
519
+ const v = slugEl.value.trim();
520
+ const taken = v && state.projects.find((p) => p.slug === v);
521
+ el.classList.remove('ok', 'bad');
522
+ if (!v) { el.textContent = ''; return; }
523
+ if (!/^[a-z0-9-]{2,40}$/.test(v)) { el.textContent = tr('key: lowercase latin letters, digits and hyphens'); el.classList.add('bad'); return; }
524
+ el.textContent = taken ? `${tr('this key is already taken by')} ${taken.name || taken.slug}` : `${tr('key is free')} · ${tr('task prefix')} ${wizPrefix(v)}`;
525
+ el.classList.add(taken ? 'bad' : 'ok');
526
+ };
527
+ slugEl.oninput = () => { slugTouched = true; showSlugState(); };
528
+ nameEl.oninput = () => { if (!slugTouched) slugEl.value = wizSlugify(nameEl.value); showSlugState(); };
443
529
  const folderSel = $('wiz-folder');
444
530
  if (folderSel) {
445
531
  const options = [
@@ -449,13 +535,29 @@ function openProjectWizard() {
449
535
  ];
450
536
  const onFolderChange = (v) => {
451
537
  $('wiz-path-row').classList.toggle('hidden', v !== '__manual__');
538
+ $('wiz-path-state').classList.toggle('hidden', v !== '__manual__');
452
539
  $('wiz-browser').classList.add('hidden');
453
540
  $('wiz-git').classList.toggle('hidden', v !== '__clone__');
541
+ const gh = $('wiz-git-hint');
542
+ gh.classList.toggle('hidden', v !== '__clone__');
543
+ if (v === '__clone__') {
544
+ const root = state.folders.root || '~';
545
+ const key = $('wiz-slug').value.trim() || 'my-project';
546
+ gh.textContent = `${tr('on create, git clone puts it on THIS computer at')} ${root}/${key}. `
547
+ + tr('An existing folder is refused, not overwritten. Git credentials are not asked here — access comes from your own git and ssh setup.');
548
+ }
454
549
  };
455
550
  const start = type === 'local' ? '' : '__manual__';
456
551
  buildSelect(folderSel, { value: start, options, onChange: onFolderChange });
457
552
  onFolderChange(start);
458
553
  $('wiz-browse').onclick = openFolderPicker;
554
+ $('wiz-path-check').onclick = () => checkPath($('wiz-path').value.trim());
555
+ $('wiz-path').oninput = () => setState('wiz-path-state', '');
556
+ }
557
+ if ($('wiz-ssh-check')) {
558
+ $('wiz-ssh-check').onclick = checkSsh;
559
+ $('wiz-server').oninput = () => setState('wiz-ssh-state', '');
560
+ $('wiz-spath').oninput = () => setState('wiz-ssh-state', '');
459
561
  }
460
562
  const skillHost = $('wiz-skill');
461
563
  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: 400px; max-width: calc(100vw - 32px); }
617
- .wiz-body { display: flex; flex-direction: column; gap: 8px; max-height: calc(100vh - 170px); overflow-y: auto; }
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; }
@@ -1129,6 +1135,7 @@ body.dragging-board .col-add-ghost { display: none; }
1129
1135
  --field-text: #c7c0b3;
1130
1136
  --warn-bg: #3a3320; --warn-border: #5a4d22; --warn-text: #e6c46e;
1131
1137
  --danger: #f0716b;
1138
+ --ok: #6fbf85;
1132
1139
  --danger-soft: #3a2222;
1133
1140
  --tl-created: #4d4940;
1134
1141
  --shadow: rgba(0, 0, 0, .55);
@@ -1155,7 +1162,7 @@ body.dragging-board .col-add-ghost { display: none; }
1155
1162
  --border-hover: #4a4946;
1156
1163
  --field-text: #c7c4ba;
1157
1164
  --warn-bg: #3a3320; --warn-border: #5a4d22; --warn-text: #e6c46e;
1158
- --danger: #e8706a; --danger-soft: #3a2222;
1165
+ --danger: #e8706a; --danger-soft: #3a2222; --ok: #6fbf85;
1159
1166
  --tl-created: #4a4946;
1160
1167
  --shadow: rgba(0, 0, 0, .55);
1161
1168
  --input-bg: #1d1d1c;
@@ -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: 'Демо-проект',