takibibase 1.14.1 → 1.15.1

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.
Files changed (4) hide show
  1. package/README.md +6 -5
  2. package/SKILL.md +13 -3
  3. package/package.json +2 -2
  4. package/takibi.mjs +114 -26
package/README.md CHANGED
@@ -5,14 +5,14 @@ npx takibibase … # zero-install: always the latest release
5
5
  npm i -g takibibase # or install once, then run `takibi …`
6
6
  ```
7
7
 
8
- Needs Node.js 22+. This directory is also the npm package source:
8
+ Needs Node.js 20+. This directory is also the npm package source:
9
9
  `takibi.mjs` stays a single zero-dependency file so the
10
10
  `/downloads/takibi.mjs` copy and the published package never drift.
11
11
 
12
12
  Thin client-side enablement for agents. The CLI calls the project, task,
13
13
  document, and Notes API routes; its behavior is documented in `SKILL.md`.
14
14
 
15
- - `takibi.mjs` — the CLI. Single-file Node ≥ 22, zero dependencies.
15
+ - `takibi.mjs` — the CLI. Single-file Node ≥ 20, zero dependencies.
16
16
  Run it from a checkout as `node tools/takibi/takibi.mjs …`, or alias it:
17
17
  `alias takibi='node <checkout>/tools/takibi/takibi.mjs'`.
18
18
  - `SKILL.md` — the `takibi-use` skill. Short on purpose; the CLI carries
@@ -44,8 +44,10 @@ document, and Notes API routes; its behavior is documented in `SKILL.md`.
44
44
  ## Give an agent
45
45
 
46
46
  Key file in place + `takibi skill --install` (puts the `takibi-use` skill
47
- into the detected agent skills dirs; `--dir` overrides, `--force`
48
- overwrites). Zero-install alternative: `takibi skill` prints the skill —
47
+ into the detected agent skills dirs; repeat `--dir <skills-root>` per
48
+ custom box (`--dir` replaces detection unless a harness flag is also
49
+ passed), `--force` refreshes an older copy). Zero-install alternative:
50
+ `takibi skill` prints the skill —
49
51
  paste it into the agent's first prompt. Acceptance: a fresh agent asks,
50
52
  searches, lists tasks, appends/searches notes, and uses `notes list --all`
51
53
  to find the current version before curation. Reads are free; task writes,
@@ -54,7 +56,6 @@ conversation; account-only routes stay yours.
54
56
 
55
57
  ## Known edges
56
58
 
57
- - `tasks get` filters the board list client-side (no single-task GET).
58
59
  - `doc download`/`delete`/`retry` refuse client-side with a pointer —
59
60
  Bearer keys can never reach account-only routes (the server answers
60
61
  those 401, not 403). `doc upload` is the exception: keys holding the
package/SKILL.md CHANGED
@@ -66,6 +66,15 @@ error hints.
66
66
  unassigned unless `--assignee` names someone; the source keeps its
67
67
  status, holder, and history, and nothing carries over. Never move a
68
68
  done card back to To do to continue it — follow up instead.
69
+ - `takibi tasks create --title "…"` — open a fresh card on the board
70
+ (`--body`, `--due <date>`, `--assignee` with the same name rules as
71
+ follow-up). Needs the create cap; naming anyone other than yourself
72
+ needs Orchestrator. The card lands at To do, unassigned unless
73
+ `--assignee` names someone — claim it before driving it.
74
+ - `takibi tasks update <id> [--title …] [--body …] [--due …]` — amend a
75
+ card without spinning a follow-up (at least one flag; empty `--due`
76
+ clears the date). A holder edits its own card; an orchestrator may
77
+ edit any card on the board. Title/body edits need the create cap.
69
78
  - `@name` in a task names the handoff target: the FIRST explicit
70
79
  `assign to @name` (or the first @name clearly meaning "who gets the
71
80
  card next"). Later @ names describing someone else's downstream step
@@ -166,13 +175,14 @@ abstains honestly instead of answering halfway.
166
175
  - Notes export/keep/remove need owner approval already given in the
167
176
  conversation. Export only creates a draft; verify it before adding
168
177
  content to Sources.
169
- - Task claim/status/handoff/artifact/escalation/follow-up writes only with owner approval
178
+ - Task create/update/claim/status/handoff/artifact/escalation/follow-up writes only with owner approval
170
179
  already given in conversation. Claim-first: a plain key must claim a card before
171
180
  moving or touching it; only orchestrators/owners accept (`review→done`),
172
181
  assign or release cards they do not hold, or archive. A holder may hand
173
182
  its own card to another eligible profile (`tasks handoff`). Title/body edits need the create cap —
174
- claim-only keys can drive a card but cannot rewrite its text. Follow-ups need
175
- the create cap too, and any granted creator may spin one off a card it does not hold.
183
+ claim-only keys can drive a card but cannot rewrite its text. Creates and
184
+ follow-ups need the create cap too, and any granted creator may spin a
185
+ follow-up off a card it does not hold.
176
186
  - Boards need a whole-collection grant: folder-only or tag-only keys 403
177
187
  on every task route (the full-collection server message says so verbatim
178
188
  when the key holds the required cap; keys lacking the cap get the generic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takibibase",
3
- "version": "1.14.1",
3
+ "version": "1.15.1",
4
4
  "description": "Thin CLI for the Takibi API: ask, search, tasks, docs, notes. Single file, zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "README.md"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=22"
15
+ "node": ">=20"
16
16
  },
17
17
  "scripts": {
18
18
  "test": "node --test notes.test.mjs skill.test.mjs tasks.test.mjs upload.test.mjs"
package/takibi.mjs CHANGED
@@ -17,7 +17,9 @@
17
17
  * fallback and for custom aliases. UUIDs pass straight through.
18
18
  * `folders` lists granted folders via GET /v1/projects/:id/folders the
19
19
  * same way (single-grant keys may omit --project; the CLI defaults it).
20
- * - There is no GET /v1/tasks/:id, so `tasks get` lists and filters.
20
+ * - Task ids are board-wide: `tasks get` reads GET /v1/tasks/:id straight
21
+ * off (no --project needed); `tasks create` posts POST /v1/tasks and
22
+ * `tasks update` patches PATCH /v1/tasks/:id, both project-scoped.
21
23
  * - Account-only routes (doc download, deletes) answer 401, not 403, to
22
24
  * Bearer [REDACTED]; `doc download` refuses client-side with a hint. Uploads
23
25
  * accept keys holding documents:ingest (`doc upload`); keys without it 403.
@@ -37,7 +39,7 @@ import { homedir } from 'node:os';
37
39
  import { basename, join } from 'node:path';
38
40
 
39
41
  /** Baked fallback; the published package re-reads package.json next door. */
40
- const BAKED_VERSION = '1.14.1';
42
+ const BAKED_VERSION = '1.15.1';
41
43
  const CLI_INFO = (() => {
42
44
  try {
43
45
  const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
@@ -145,6 +147,10 @@ async function maybeNotifyUpdate({ json = false } = {}) {
145
147
  ? 'hint: download the latest takibi.mjs from https://takibibase.com/downloads/takibi.mjs'
146
148
  : 'hint: `npm i -g takibibase@latest`, or `npx takibibase@latest` for one-shot runs.',
147
149
  );
150
+ // Stale skills misroute crews (the upgrade bumps skill copy too), so
151
+ // the version nudge carries the refresh recipe. Single-file copies
152
+ // carry no SKILL.md to install — nothing to refresh there.
153
+ if (!CLI_INFO.singleFile) err('hint: then `takibi skill --install --force` refreshes agent skills (repeat `--dir <root>` per custom box).');
148
154
  } catch {
149
155
  // Offline, broken cache, slow registry: stay silent, run the command.
150
156
  }
@@ -302,7 +308,7 @@ function hintFor(status, serverMessage) {
302
308
  * One API call. Verbose logs method + URL + status only — headers (which
303
309
  * carry the key) are never logged, printed, or interpolated anywhere.
304
310
  */
305
- async function api(method, path, { query = null, body = null, form = null, key = null, baseUrl, verbose = false, label = null } = {}) {
311
+ async function api(method, path, { query = null, body = null, form = null, key = null, baseUrl, verbose = false, label = null, blockHint = null } = {}) {
306
312
  const qs = query ? `?${query.toString()}` : '';
307
313
  const url = `${baseUrl}${path}${qs}`;
308
314
  const headers = { accept: 'application/json', 'user-agent': USER_AGENT };
@@ -330,6 +336,13 @@ async function api(method, path, { query = null, body = null, form = null, key =
330
336
  try {
331
337
  data = await res.json();
332
338
  } catch {
339
+ const responseType = res.headers.get('content-type') ?? '';
340
+ if (res.status === 403 && responseType.includes('text/html')) {
341
+ throw new CliError(`${label ?? path}: blocked by network security before reaching the API (HTTP 403).`, {
342
+ status: res.status,
343
+ hint: blockHint ?? 'The hosting filter flagged this request. Reword anything command-like and retry.',
344
+ });
345
+ }
333
346
  throw new CliError(`Unexpected response from ${label ?? path} (HTTP ${res.status}, not JSON).`, {
334
347
  status: res.status,
335
348
  hint: `Is ${baseUrl} the Takibi API? \`takibi version\` says what is serving there.`,
@@ -361,8 +374,10 @@ Commands:
361
374
  search -q "..." Ranked chunks (snippets + metadata, no full text)
362
375
  tasks list Board cards for the project
363
376
  tasks get <id> One card with body + artifacts
377
+ tasks create Open a fresh card [--title/--body/--assignee/--due]
364
378
  tasks claim <id> Claim an open to-do card (atomic, one winner)
365
379
  tasks status <id> <st> Move a card: todo | in_progress | review | done [--note <text>]
380
+ tasks update <id> Amend a card's title, body, or due date [--title/--body/--due]
366
381
  tasks handoff <id> <profile> Hand a card you hold to another profile [--review --note <text>]
367
382
  tasks follow-up <id> Spin a fresh linked todo off any card [--title/--body/--assignee/--due]
368
383
  tasks escalate <id> Ask the Founder for help on a held card [--reason <text>]
@@ -406,6 +421,11 @@ Per-command options:
406
421
  tasks escalate: --reason <text> (required, max 500 chars — what do you need from the Founder?)
407
422
  tasks follow-up: --title <text> (required, max 300 chars), --body <text> (max 20000 chars),
408
423
  --assignee <name-or-uuid>, --due <date>; the source card is never modified
424
+ tasks create: --title <text> (required, max 300 chars), --body <text> (max 20000 chars),
425
+ --assignee <name-or-uuid>, --due <date>; needs the create cap,
426
+ lands at To do unassigned unless --assignee names someone
427
+ tasks update: --title/--body/--due (at least one; same limits as create;
428
+ empty --due clears the date)
409
429
  tasks get: sent-back cards carry previousReviewNote, the last handoff note
410
430
  tasks artifact add <url>: --note <text> --size <bytes> --hash <hex> (links only)
411
431
  tasks artifact add --markdown <file|->: --title <text> --note <text> (content from file or stdin -, max 50000 chars; title max 140 chars)
@@ -422,6 +442,7 @@ Per-command options:
422
442
  notes keep/remove: <id> <expected-version> from notes list --all
423
443
  projects: --add <name> <uuid>
424
444
  skill: --install [--force] [--agents] [--claude] [--codex] [--dir <skills-root>]
445
+ (repeat --dir per custom box; --dir alone skips detection)
425
446
 
426
447
  Setup: save the owner-provided key (one <publicId>.<secret> line) to
427
448
  ~/.takibi/key (chmod 600). The API defaults to Takibi's servers; only
@@ -432,7 +453,7 @@ to probe the API and \`takibi projects\` to see this key's scope
432
453
  Agents: \`takibi skill --install\` puts the takibi-use skill where
433
454
  assistants look for it.
434
455
 
435
- Reads are free. Export stamps included notes. Task claim/status/handoff/artifact/escalation/follow-up writes and doc uploads
456
+ Reads are free. Export stamps included notes. Task create/update/claim/status/handoff/artifact/escalation/follow-up writes and doc uploads
436
457
  need owner approval in conversation (uploads also need the Uploads grant on the key).
437
458
  Account-only routes (delete, retry, download originals, PATCH docs/projects)
438
459
  are never the agent's to call — ask the account owner.
@@ -729,16 +750,47 @@ async function cmdTasks(tokens, globals) {
729
750
  if (sub === 'get') {
730
751
  const [id, extra] = rest;
731
752
  if (!id || extra) throw usageError('Usage: takibi tasks get <id>');
753
+ // Ids are board-wide: no --project to resolve (same as follow-up).
754
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
755
+ const data = await api('GET', `/v1/tasks/${id}`, { ...ctx, label: 'tasks get' });
756
+ if (ctx.json) out(JSON.stringify({ task: data.task }, null, 2));
757
+ else renderTask(data.task);
758
+ return;
759
+ }
760
+ if (sub === 'create') {
761
+ const usage = 'Usage: takibi tasks create --title <text> [--body <text>] [--assignee <name-or-uuid>] [--due <date>]';
762
+ let title;
763
+ let body;
764
+ let assignee;
765
+ let due;
766
+ for (let i = 0; i < rest.length; i++) {
767
+ const f = rest[i];
768
+ if (f === '--title') title = rest[++i];
769
+ else if (f === '--body') body = rest[++i];
770
+ else if (f === '--assignee') assignee = rest[++i];
771
+ else if (f === '--due') due = rest[++i];
772
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. ${usage}`);
773
+ }
774
+ // Validate everything before touching the key or network, mirroring
775
+ // the server limits so failures name the flag instead of 400ing.
776
+ if (title === undefined || title.trim() === '') throw usageError('--title needs non-empty text.');
777
+ if (title.trim().length > 300) throw usageError('--title is max 300 chars.');
778
+ if (body !== undefined && body.length > 20000) throw usageError('--body is max 20000 chars.');
779
+ if (due !== undefined && due.trim() === '') throw usageError('--due needs a date.');
780
+ if (assignee !== undefined && String(assignee).trim() === '') throw usageError('--assignee needs a profile name or UUID.');
732
781
  const ctx = await ctxFor(globals, { needKey: true });
733
- let tasks = await listTasks(ctx, false);
734
- let found = tasks.find((t) => t.id === id);
735
- if (!found) {
736
- tasks = await listTasks(ctx, true);
737
- found = tasks.find((t) => t.id === id);
782
+ if (assignee !== undefined) {
783
+ const v = String(assignee).trim();
784
+ assignee = UUID_RE.test(v) ? v : (await resolveAssignee(v, ctx, 'tasks create')).id;
738
785
  }
739
- if (!found) throw new CliError(`tasks get: No such task in this project (${id}).`, { status: 404, hint: hintFor(404, '') });
740
- if (ctx.json) out(JSON.stringify({ task: found }, null, 2));
741
- else renderTask(found);
786
+ const data = await api('POST', '/v1/tasks', {
787
+ ...ctx,
788
+ query: qparams([['projectId', ctx.projectId]]),
789
+ body: { title: title.trim(), body, assigneeProfileId: assignee, dueAt: due },
790
+ label: 'tasks create',
791
+ });
792
+ if (ctx.json) out(JSON.stringify(data, null, 2));
793
+ else renderTask(data.task);
742
794
  return;
743
795
  }
744
796
  if (sub === 'claim') {
@@ -803,6 +855,41 @@ async function cmdTasks(tokens, globals) {
803
855
  else renderTask(data.task);
804
856
  return;
805
857
  }
858
+ if (sub === 'update') {
859
+ const usage = 'Usage: takibi tasks update <id> [--title <text>] [--body <text>] [--due <date>]';
860
+ const [id, ...flagTokens] = rest;
861
+ if (!id || id.startsWith('--')) throw usageError(usage);
862
+ let title;
863
+ let body;
864
+ let due;
865
+ for (let i = 0; i < flagTokens.length; i++) {
866
+ const f = flagTokens[i];
867
+ if (f === '--title') title = flagTokens[++i];
868
+ else if (f === '--body') body = flagTokens[++i];
869
+ else if (f === '--due') due = flagTokens[++i];
870
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. ${usage}`);
871
+ }
872
+ if (title === undefined && body === undefined && due === undefined) {
873
+ throw usageError(`Nothing to change — pass at least one of --title, --body, --due. ${usage}`);
874
+ }
875
+ if (title !== undefined && title.trim() === '') throw usageError('--title needs non-empty text.');
876
+ if (title !== undefined && title.trim().length > 300) throw usageError('--title is max 300 chars.');
877
+ if (body !== undefined && body.length > 20000) throw usageError('--body is max 20000 chars.');
878
+ // An explicit empty --due clears the date (the server treats '' and
879
+ // null as clear). Create keeps rejecting it: omit and clear are the
880
+ // same on POST.
881
+ if (due !== undefined && due.trim() === '') due = null;
882
+ const ctx = await ctxFor(globals, { needKey: true });
883
+ const data = await api('PATCH', `/v1/tasks/${id}`, {
884
+ ...ctx,
885
+ query: qparams([['projectId', ctx.projectId]]),
886
+ body: { title: title?.trim(), body, dueAt: due },
887
+ label: 'tasks update',
888
+ });
889
+ if (ctx.json) out(JSON.stringify(data, null, 2));
890
+ else renderTask(data.task);
891
+ return;
892
+ }
806
893
  if (sub === 'artifact' || sub === 'artifacts') {
807
894
  const LINK_USAGE = 'Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]';
808
895
  const MD_USAGE = 'Usage: takibi tasks artifact add <taskId> --markdown <file|-> [--title <t>] [--note <t>]';
@@ -945,7 +1032,7 @@ async function cmdTasks(tokens, globals) {
945
1032
  // Validate everything before touching the key or network, mirroring
946
1033
  // the server limits so failures name the flag instead of 400ing.
947
1034
  if (title === undefined || title.trim() === '') throw usageError('--title needs non-empty text.');
948
- if (title.length > 300) throw usageError('--title is max 300 chars.');
1035
+ if (title.trim().length > 300) throw usageError('--title is max 300 chars.');
949
1036
  if (body !== undefined && body.length > 20000) throw usageError('--body is max 20000 chars.');
950
1037
  if (due !== undefined && due.trim() === '') throw usageError('--due needs a date.');
951
1038
  if (assignee !== undefined && String(assignee).trim() === '') throw usageError('--assignee needs a profile name or UUID.');
@@ -970,7 +1057,7 @@ async function cmdTasks(tokens, globals) {
970
1057
  else renderTask(data.task);
971
1058
  return;
972
1059
  }
973
- throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | claim | status | handoff | follow-up | escalate | deescalate | artifact add | artifact show.`);
1060
+ throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | create | claim | status | update | handoff | follow-up | escalate | deescalate | artifact add | artifact show.`);
974
1061
  }
975
1062
 
976
1063
  async function cmdDoc(tokens, globals) {
@@ -1049,16 +1136,16 @@ async function cmdDoc(tokens, globals) {
1049
1136
  if (bytes.length > 10 * 1024 * 1024) {
1050
1137
  throw new CliError(`${path} is over the 10MB per-file limit.`, { hint: 'Split it and upload the parts.' });
1051
1138
  }
1052
- const form = new FormData();
1053
- form.set('file', new File([bytes], basename(path)));
1139
+ // Base64 keeps file bytes opaque to edge filters, which pattern-match raw multipart bodies.
1054
1140
  const data = await api('POST', '/v1/documents', {
1055
1141
  ...ctx,
1056
1142
  query: qparams([
1057
1143
  ['projectId', ctx.projectId],
1058
1144
  ['folderId', ctx.folder],
1059
1145
  ]),
1060
- form,
1146
+ body: { name: basename(path), contentBase64: bytes.toString('base64') },
1061
1147
  label: 'doc upload',
1148
+ blockHint: 'The hosting filter flagged this file. Reword command-like text and upload it again.',
1062
1149
  });
1063
1150
  if (ctx.json) {
1064
1151
  out(JSON.stringify(data, null, 2));
@@ -1437,7 +1524,7 @@ const SKILL_TARGETS = [
1437
1524
  { flag: '--codex', harness: '.codex', dir: '.codex/skills' },
1438
1525
  ];
1439
1526
 
1440
- const SKILL_USAGE = 'Usage: takibi skill [--install [--force] [--agents] [--claude] [--codex] [--dir <skills-root>]]';
1527
+ const SKILL_USAGE = 'Usage: takibi skill [--install [--force] [--agents] [--claude] [--codex] [--dir <skills-root>]…] (repeat --dir per custom box)';
1441
1528
 
1442
1529
  /** First-run skill nudge. Stderr only (stdout stays clean), best-effort,
1443
1530
  * never fails the command. Skipped for --json, in CI, with
@@ -1464,7 +1551,7 @@ function maybeNudgeSkill({ json = false } = {}) {
1464
1551
  // Read-only home: nudge once per process run instead of daily.
1465
1552
  }
1466
1553
  err('takibi: no takibi-use skill installed for your agents.');
1467
- err('hint: `takibi skill --install` puts it where assistants look.');
1554
+ err('hint: `takibi skill --install` puts it where assistants look (repeat `--dir <skills-root>` per custom box; `--force` refreshes).');
1468
1555
  } catch {
1469
1556
  // Nudges never fail commands.
1470
1557
  }
@@ -1473,19 +1560,20 @@ function maybeNudgeSkill({ json = false } = {}) {
1473
1560
  function cmdSkill(tokens, globals) {
1474
1561
  let install = false;
1475
1562
  let force = false;
1476
- let customRoot = null;
1563
+ const customRoots = [];
1477
1564
  const picked = new Set();
1478
1565
  for (let i = 0; i < tokens.length; i++) {
1479
1566
  const t = tokens[i];
1480
1567
  if (t === '--install') install = true;
1481
1568
  else if (t === '--force') force = true;
1482
1569
  else if (t === '--dir') {
1483
- customRoot = tokens[++i];
1484
- if (!customRoot) throw usageError('`--dir` needs a skills root. ' + SKILL_USAGE);
1570
+ const root = tokens[++i];
1571
+ if (!root) throw usageError('`--dir` needs a skills root. ' + SKILL_USAGE);
1572
+ customRoots.push(root);
1485
1573
  } else if (t === '--agents' || t === '--claude' || t === '--codex') picked.add(t);
1486
1574
  else throw usageError(`Unexpected ${JSON.stringify(t)}. ${SKILL_USAGE}`);
1487
1575
  }
1488
- if (!install && (force || picked.size > 0 || customRoot)) {
1576
+ if (!install && (force || picked.size > 0 || customRoots.length > 0)) {
1489
1577
  throw usageError(`Those flags need \`--install\`. ${SKILL_USAGE}`);
1490
1578
  }
1491
1579
  const text = skillText();
@@ -1504,17 +1592,17 @@ function cmdSkill(tokens, globals) {
1504
1592
  }
1505
1593
  const home = skillHome();
1506
1594
  let targets;
1507
- if (customRoot && picked.size === 0) targets = [];
1595
+ if (customRoots.length > 0 && picked.size === 0) targets = [];
1508
1596
  else if (picked.size === 0) {
1509
1597
  const detected = SKILL_TARGETS.filter((t) => existsSync(join(home, t.harness)));
1510
1598
  targets = detected.length > 0 ? detected : [SKILL_TARGETS[0]];
1511
1599
  } else {
1512
1600
  targets = SKILL_TARGETS.filter((t) => picked.has(t.flag));
1513
1601
  }
1514
- if (customRoot) targets = [...targets, { flag: '--dir', custom: true }];
1602
+ for (const root of customRoots) targets.push({ flag: '--dir', custom: true, root });
1515
1603
  const results = [];
1516
1604
  for (const t of targets) {
1517
- const dir = t.custom ? join(customRoot, SKILL_NAME) : join(home, t.dir, SKILL_NAME);
1605
+ const dir = t.custom ? join(t.root, SKILL_NAME) : join(home, t.dir, SKILL_NAME);
1518
1606
  const dest = join(dir, SKILL_FILE);
1519
1607
  if (existsSync(dest)) {
1520
1608
  let same = false;