(path: string, existOk: boolean)
| 793 | } |
| 794 | |
| 795 | private async _mkdirRecursive(path: string, existOk: boolean): Promise<void> { |
| 796 | // Split path into components and create each level. |
| 797 | const parts = path.split('/').filter(Boolean); |
| 798 | let current = path.startsWith('/') ? '/' : ''; |
| 799 | const lastIndex = parts.length - 1; |
| 800 | for (const [i, part] of parts.entries()) { |
| 801 | current = current ? join(current, part) : part; |
| 802 | |
| 803 | const isFinal = i === lastIndex; |
| 804 | |
| 805 | // eslint-disable-next-line no-await-in-loop |
| 806 | const exists = await sftpExists(this._sftp, current); |
| 807 | if (exists) { |
| 808 | // For intermediate components, it's fine (and expected) for the |
| 809 | // path to already exist. For the final target, honor `existOk`. |
| 810 | if (isFinal && !existOk) { |
| 811 | throw new KaosFileExistsError(`${current} already exists`); |
| 812 | } |
| 813 | // Regardless of whether this is an intermediate or the final |
| 814 | // component, an existing path must actually be a directory. |
| 815 | // An intermediate non-directory would cause the next `sftpMkdir` |
| 816 | // to fail with a confusing error; a final non-directory would |
| 817 | // otherwise be silently accepted when `existOk` is true. |
| 818 | // eslint-disable-next-line no-await-in-loop |
| 819 | const st = await sftpStat(this._sftp, current); |
| 820 | if (!st.isDirectory()) { |
| 821 | throw new KaosFileExistsError(`${current} already exists but is not a directory`); |
| 822 | } |
| 823 | continue; |
| 824 | } |
| 825 | try { |
| 826 | // eslint-disable-next-line no-await-in-loop |
| 827 | await sftpMkdir(this._sftp, current); |
| 828 | } catch (error) { |
| 829 | // Race condition: another process may have created it. |
| 830 | // eslint-disable-next-line no-await-in-loop |
| 831 | const nowExists = await sftpExists(this._sftp, current); |
| 832 | if (!nowExists) throw new Error(`Failed to create directory: ${current}`, { cause: error }); |
| 833 | // A raced path must still be a directory. Another process may have |
| 834 | // created a regular file at the same pathname after our exists() |
| 835 | // check but before mkdir(), which must remain a hard conflict. |
| 836 | // eslint-disable-next-line no-await-in-loop |
| 837 | const st = await sftpStat(this._sftp, current); |
| 838 | if (!st.isDirectory()) { |
| 839 | throw new KaosFileExistsError(`${current} already exists but is not a directory`); |
| 840 | } |
| 841 | // If the final component lost a race and existOk=false, surface the |
| 842 | // conflict to match the non-race path above. |
| 843 | if (isFinal && !existOk) { |
| 844 | throw new KaosFileExistsError(`${current} already exists`); |
| 845 | } |
| 846 | } |
| 847 | } |
| 848 | } |
| 849 | |
| 850 | // ── Process execution ────────────────────────────────────────────── |
| 851 |
no test coverage detected