petari 0.1.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.
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export const DEFAULT_CONFIG = {
5
+ downloadsDir: null,
6
+ newFile: { encoding: "utf8", eol: "lf" },
7
+ historyLimit: null,
8
+ vscodeCommand: "code",
9
+ };
10
+ /** グローバル設定のパス (§10)。Windows は %APPDATA%、他は XDG (~/.config) */
11
+ export function globalConfigPath() {
12
+ if (process.platform === "win32" && process.env["APPDATA"] !== undefined) {
13
+ return join(process.env["APPDATA"], "petari", "config.json");
14
+ }
15
+ const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
16
+ return join(base, "petari", "config.json");
17
+ }
18
+ function readConfigFile(path) {
19
+ if (!existsSync(path))
20
+ return {};
21
+ try {
22
+ return JSON.parse(readFileSync(path, "utf8"));
23
+ }
24
+ catch (e) {
25
+ throw new Error(`${path} を JSON として読めません: ${e instanceof Error ? e.message : String(e)}`);
26
+ }
27
+ }
28
+ /** JSON 由来の値は型注釈を素通りするため、使う前に実行時検証する */
29
+ function validateConfig(c) {
30
+ const enc = c.newFile.encoding;
31
+ if (enc !== "utf8" && enc !== "shift_jis") {
32
+ throw new Error(`config の newFile.encoding が不正です: ${String(enc)} ("utf8" | "shift_jis")`);
33
+ }
34
+ const eol = c.newFile.eol;
35
+ if (eol !== "lf" && eol !== "crlf") {
36
+ throw new Error(`config の newFile.eol が不正です: ${String(eol)} ("lf" | "crlf")`);
37
+ }
38
+ const limit = c.historyLimit;
39
+ if (limit !== null && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1)) {
40
+ throw new Error(`config の historyLimit が不正です: ${String(limit)} (null または 1 以上の整数)`);
41
+ }
42
+ if (typeof c.vscodeCommand !== "string") {
43
+ throw new Error("config の vscodeCommand が不正です (文字列を指定)");
44
+ }
45
+ if (c.downloadsDir !== null && typeof c.downloadsDir !== "string") {
46
+ throw new Error("config の downloadsDir が不正です (null または文字列を指定)");
47
+ }
48
+ return c;
49
+ }
50
+ /** 既定 < グローバル < プロジェクトの順でマージする (プロジェクト優先・§10) */
51
+ export function loadConfig(root) {
52
+ const global = readConfigFile(globalConfigPath());
53
+ const project = readConfigFile(join(root, ".petari", "config.json"));
54
+ return validateConfig({
55
+ ...DEFAULT_CONFIG,
56
+ ...global,
57
+ ...project,
58
+ newFile: {
59
+ ...DEFAULT_CONFIG.newFile,
60
+ ...(global.newFile ?? {}),
61
+ ...(project.newFile ?? {}),
62
+ },
63
+ });
64
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ダウンロードフォルダの解決と changes*.md の自動検出 (§4.1, §10)。
3
+ * Windows は Known Folder (レジストリ) から取得し、OneDrive リダイレクトされた
4
+ * 社用 PC でも正しいフォルダを見つける。
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ import { readdirSync, statSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { promisify } from "node:util";
11
+ const execFileP = promisify(execFile);
12
+ /** Known Folder GUID: Downloads */
13
+ const DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}";
14
+ const USER_SHELL_FOLDERS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
15
+ /** %USERPROFILE% 等の Windows 環境変数参照を展開する */
16
+ export function expandWindowsEnv(value) {
17
+ return value.replace(/%([^%]+)%/g, (whole, name) => {
18
+ return process.env[name] ?? process.env[name.toUpperCase()] ?? whole;
19
+ });
20
+ }
21
+ /** `reg query ... /v {GUID}` の出力から値 (REG_EXPAND_SZ / REG_SZ) を取り出す */
22
+ export function parseRegQueryValue(stdout, guid) {
23
+ for (const line of stdout.split(/\r?\n/)) {
24
+ if (!line.toUpperCase().includes(guid.toUpperCase()))
25
+ continue;
26
+ const m = /REG_(?:EXPAND_)?SZ\s+(.+)$/.exec(line);
27
+ if (m !== null)
28
+ return m[1].trim();
29
+ }
30
+ return null;
31
+ }
32
+ export async function resolveDownloadsDir(configured) {
33
+ if (configured !== null && configured !== "")
34
+ return configured;
35
+ if (process.platform === "win32") {
36
+ try {
37
+ const { stdout } = await execFileP("reg", [
38
+ "query",
39
+ USER_SHELL_FOLDERS_KEY,
40
+ "/v",
41
+ DOWNLOADS_GUID,
42
+ ]);
43
+ const value = parseRegQueryValue(stdout, DOWNLOADS_GUID);
44
+ if (value !== null)
45
+ return expandWindowsEnv(value);
46
+ }
47
+ catch {
48
+ // reg が使えない場合は既定にフォールバック
49
+ }
50
+ }
51
+ return join(homedir(), "Downloads");
52
+ }
53
+ /**
54
+ * changes*.md のうち更新が maxAgeMinutes 以内のものを新しい順に返す (§4.1)。
55
+ * 古いファイルの誤適用を防ぐため、既定 30 分より古いものは対象外。
56
+ */
57
+ export function findRecentChangesFiles(dir, now, maxAgeMinutes = 30) {
58
+ let names;
59
+ try {
60
+ names = readdirSync(dir);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ const found = [];
66
+ for (const name of names) {
67
+ if (!/^changes.*\.md$/i.test(name))
68
+ continue;
69
+ const path = join(dir, name);
70
+ try {
71
+ const st = statSync(path);
72
+ if (!st.isFile())
73
+ continue;
74
+ if (now.getTime() - st.mtimeMs <= maxAgeMinutes * 60_000) {
75
+ found.push({ path, mtime: st.mtime });
76
+ }
77
+ }
78
+ catch {
79
+ // 読めないファイルは無視
80
+ }
81
+ }
82
+ return found.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
83
+ }
@@ -0,0 +1,59 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, sep } from "node:path";
4
+ /**
5
+ * absPath の「存在する最も近い祖先ディレクトリ」の実体 (realpath) が
6
+ * root 配下にあるかを検証する (§9 の defense-in-depth)。
7
+ * プロジェクト内の symlink ディレクトリを経由したルート外への書き込みを防ぐ。
8
+ */
9
+ export function isInsideRoot(root, absPath) {
10
+ let rootReal;
11
+ try {
12
+ rootReal = realpathSync(root);
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ let dir = dirname(absPath);
18
+ for (;;) {
19
+ try {
20
+ const real = realpathSync(dir);
21
+ return real === rootReal || real.startsWith(rootReal + sep);
22
+ }
23
+ catch {
24
+ const parent = dirname(dir);
25
+ if (parent === dir)
26
+ return false;
27
+ dir = parent;
28
+ }
29
+ }
30
+ }
31
+ /** 適用対象ファイルの状態を読む (シンボリックリンク検出は lstat・§9) */
32
+ export function readFileState(absPath) {
33
+ let symlink = false;
34
+ let exists = false;
35
+ try {
36
+ const st = lstatSync(absPath);
37
+ symlink = st.isSymbolicLink();
38
+ exists = true;
39
+ }
40
+ catch {
41
+ return { exists: false, symlink: false, bytes: null };
42
+ }
43
+ if (symlink)
44
+ return { exists, symlink, bytes: null };
45
+ return { exists, symlink, bytes: new Uint8Array(readFileSync(absPath)) };
46
+ }
47
+ export function writeBytes(absPath, bytes) {
48
+ mkdirSync(dirname(absPath), { recursive: true });
49
+ writeFileSync(absPath, bytes);
50
+ }
51
+ export function deleteFile(absPath) {
52
+ unlinkSync(absPath);
53
+ }
54
+ export function fileExists(absPath) {
55
+ return existsSync(absPath);
56
+ }
57
+ export function sha256(bytes) {
58
+ return createHash("sha256").update(bytes).digest("hex");
59
+ }
@@ -0,0 +1,16 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileP = promisify(execFile);
4
+ /**
5
+ * 未コミット変更のあるファイル一覧 (§9)。
6
+ * Git が使えない / リポジトリでない場合は null (Git は必須ではない)。
7
+ */
8
+ export async function gitDirtyFiles(root) {
9
+ try {
10
+ const { stdout } = await execFileP("git", ["status", "--porcelain"], { cwd: root });
11
+ return stdout.split("\n").filter((l) => l.trim() !== "");
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 履歴ディレクトリ (.petari/history/) の読み書き (§5)。
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { sha256, writeBytes } from "./files.js";
7
+ export function petariDir(root) {
8
+ return join(root, ".petari");
9
+ }
10
+ export function historyRoot(root) {
11
+ return join(petariDir(root), "history");
12
+ }
13
+ /** 履歴 ID: YYYY-MM-DD_HHmm。同分内の衝突は連番サフィックス (§5) */
14
+ export function createHistoryId(root, now) {
15
+ const pad = (n) => String(n).padStart(2, "0");
16
+ const base = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}`;
17
+ let id = base;
18
+ for (let n = 2; existsSync(join(historyRoot(root), id)); n++) {
19
+ id = `${base}-${n}`;
20
+ }
21
+ return id;
22
+ }
23
+ /**
24
+ * 履歴保存は 2 段階 (§4.1 手順 4)。
25
+ * begin: 書き換え前に changes.md 原本と before/ を保存 (途中クラッシュでも復元材料が残る)
26
+ * finish: 書き換え後に after/ と manifest.json を保存
27
+ */
28
+ export function beginHistory(root, id, changesText, before) {
29
+ const dir = join(historyRoot(root), id);
30
+ mkdirSync(dir, { recursive: true });
31
+ writeFileSync(join(dir, "changes.md"), changesText, "utf8");
32
+ for (const [path, bytes] of before) {
33
+ if (bytes !== null)
34
+ writeBytes(join(dir, "before", path), bytes);
35
+ }
36
+ return dir;
37
+ }
38
+ export function finishHistory(root, id, manifest, after) {
39
+ const dir = join(historyRoot(root), id);
40
+ for (const [path, bytes] of after) {
41
+ if (bytes !== null)
42
+ writeBytes(join(dir, "after", path), bytes);
43
+ }
44
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
45
+ }
46
+ export function listHistoryIds(root) {
47
+ const dir = historyRoot(root);
48
+ if (!existsSync(dir))
49
+ return [];
50
+ return readdirSync(dir, { withFileTypes: true })
51
+ .filter((e) => e.isDirectory())
52
+ .map((e) => e.name)
53
+ .sort();
54
+ }
55
+ /** historyLimit を超えた古い履歴を削除する (§10)。null = 無制限 */
56
+ export function pruneHistory(root, limit) {
57
+ if (limit === null || limit < 1)
58
+ return [];
59
+ const ids = listHistoryIds(root);
60
+ const excess = ids.slice(0, Math.max(0, ids.length - limit));
61
+ for (const id of excess) {
62
+ rmSync(join(historyRoot(root), id), { recursive: true, force: true });
63
+ }
64
+ return excess;
65
+ }
66
+ export function readManifest(root, id) {
67
+ const p = join(historyRoot(root), id, "manifest.json");
68
+ if (!existsSync(p))
69
+ return null;
70
+ return JSON.parse(readFileSync(p, "utf8"));
71
+ }
72
+ export { sha256 };
@@ -0,0 +1,12 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /** y/N 確認プロンプト。--yes 時は呼び出し側でスキップする */
3
+ export async function confirm(question) {
4
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5
+ try {
6
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
7
+ return answer === "y" || answer === "yes";
8
+ }
9
+ finally {
10
+ rl.close();
11
+ }
12
+ }
@@ -0,0 +1,20 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ /**
4
+ * プロジェクトルートの特定 (§9)。
5
+ * カレントから上方向に最も近い .git を探索。なければカレント。--root で明示上書き。
6
+ * ネスト構成 (メニュー単位の Git リポジトリ) では実行場所のリポジトリがルートになる。
7
+ */
8
+ export function findProjectRoot(cwd, override) {
9
+ if (override !== undefined)
10
+ return resolve(cwd, override);
11
+ let dir = resolve(cwd);
12
+ for (;;) {
13
+ if (existsSync(join(dir, ".git")))
14
+ return dir;
15
+ const parent = dirname(dir);
16
+ if (parent === dir)
17
+ return resolve(cwd);
18
+ dir = parent;
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 端末出力のサニタイズ (セキュリティレビュー指摘 4)。
3
+ * changes.md 由来の文字列 (SEARCH 引用・プレビュー・パス表示) には
4
+ * ANSI エスケープ等を仕込めるため、\n \t 以外の C0/C1 制御文字を除去してから出力する。
5
+ * クリップボードへコピーするレポート本文は原文のまま (これは端末を通らない)。
6
+ */
7
+ // C0/C1 制御文字 (\n \t 以外) に加え、表示順序を偽装できる双方向制御文字
8
+ // (Trojan Source 対策: LRM/RLM/ALM・LRE〜PDF・LRI〜PDI) も除去する
9
+ const CONTROL_CHARS = new RegExp("[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]", "g");
10
+ export function sanitizeForTerminal(s) {
11
+ return s.replace(CONTROL_CHARS, "");
12
+ }
13
+ export const out = (s) => void process.stdout.write(sanitizeForTerminal(s) + "\n");
14
+ export const err = (s) => void process.stderr.write(sanitizeForTerminal(s) + "\n");
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "petari",
3
+ "version": "0.1.0",
4
+ "description": "petari — paste AI chat patches onto your codebase",
5
+ "keywords": [
6
+ "cli",
7
+ "ai",
8
+ "patch",
9
+ "diff",
10
+ "apply"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Yuki Sakakibara",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ishibashi0112/petari.git"
17
+ },
18
+ "homepage": "https://github.com/ishibashi0112/petari#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/ishibashi0112/petari/issues"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "petari": "./dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20.19.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^24.3.0",
34
+ "typescript": "^5.9.2",
35
+ "vitest": "^3.2.4"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "dev": "node src/cli.ts",
43
+ "gen:sjis": "node scripts/generate-sjis-table.ts"
44
+ }
45
+ }