nyaucast 0.0.0-stage → 0.0.2
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/README.md +34 -2
- package/bin/nyaucast.js +3 -0
- package/dist/channel-registry/schema.js +3 -0
- package/dist/collections/directories.js +37 -0
- package/dist/collections/gate-operations.js +28 -0
- package/dist/db/collections.js +45 -0
- package/dist/db/gates.js +65 -0
- package/dist/db/local-store.js +44 -0
- package/dist/db/read-model.js +36 -0
- package/dist/db/schema.js +27 -0
- package/dist/index.js +99 -0
- package/dist/mcp.js +25 -0
- package/dist/tools/collection.status.js +27 -0
- package/dist/tools/plan.checkTitle.js +23 -0
- package/dist/tools/plan.init.js +60 -0
- package/dist/youtube/auth.js +168 -0
- package/dist/youtube/client.js +116 -0
- package/dist/youtube/oauth-loopback.js +64 -0
- package/drizzle/0000_open_payback.sql +52 -0
- package/drizzle/meta/0000_snapshot.json +181 -0
- package/drizzle/meta/_journal.json +13 -0
- package/package.json +62 -3
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nyaucast
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
YouTube チャンネル運営を自動化するツールキット。agent (Claude Code / Codex) が直接呼べる型付き MCP tool として、チャンネル運営のワークフロー知識を提供する。
|
|
4
|
+
|
|
5
|
+
- 用語集: [CONTEXT.md](https://github.com/daiki-beppu/nyaucast/blob/main/CONTEXT.md)
|
|
6
|
+
- アーキテクチャ規約: [docs/adr/0001-thin-architecture.md](https://github.com/daiki-beppu/nyaucast/blob/main/docs/adr/0001-thin-architecture.md)
|
|
7
|
+
- 出自: [00-automation ADR-0021](https://github.com/daiki-beppu/youtube-automation/blob/main/docs/adr/0021-separate-repo-restart.md)(Python 版からの転換の経緯)
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
前提: [Node](https://nodejs.org)(ホスト供給。開発・CI の版線は `package.json` の `devEngines.runtime` が定める)と pnpm v12 native binary。pnpm の版は `packageManager` の exact pin が定める。旧 pnpm からの自動切替は使わないため、既存環境はリポジトリ外で一度だけ pin 版へ更新する(pnpm 未導入なら[公式のインストール手順](https://pnpm.io/installation)を使う)。
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm_version=$(node -p 'require("./package.json").packageManager.split("@").at(-1)')
|
|
15
|
+
(cd /tmp && pnpm self-update "$pnpm_version")
|
|
16
|
+
pnpm --version # packageManager の pin と一致すること
|
|
17
|
+
vp install # 依存導入(初回と worktree 作成後。lockfile 検出で pnpm へ委譲)
|
|
18
|
+
pnpm run check # 全ゲートを実行
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
lockfile と `package.json` の乖離は check の最初のゲートが検出する。
|
|
22
|
+
|
|
23
|
+
全ゲートの入口は `pnpm run check`。ゲートの構成は `package.json` の `check` script だけが定義する。
|
|
24
|
+
|
|
25
|
+
## Status
|
|
26
|
+
|
|
27
|
+
v0.1.0 に向けて開発中。ゲートは first-party チャンネルでの dogfood 完走(collection フルライフサイクル 1 周)。Python 版 (`youtube-channels-automation`) は nyaucast が実運用カバレッジに達するまでメンテナンスモードで維持される。
|
|
28
|
+
|
|
29
|
+
## 旧称 tayk からの移行
|
|
30
|
+
|
|
31
|
+
旧称 tayk の設定を使っている場合は、次のコマンドで設定ディレクトリを移す。
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
mv ~/.config/tayk ~/.config/nyaucast
|
|
35
|
+
```
|
package/bin/nyaucast.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function relativeCollectionDirectory(id) {
|
|
4
|
+
return `collections/${id}`;
|
|
5
|
+
}
|
|
6
|
+
function isNotFound(error) {
|
|
7
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
8
|
+
}
|
|
9
|
+
async function createDirectory(root, id) {
|
|
10
|
+
await mkdir(root, { recursive: true });
|
|
11
|
+
await mkdir(join(root, id), { recursive: false });
|
|
12
|
+
return relativeCollectionDirectory(id);
|
|
13
|
+
}
|
|
14
|
+
export function createCollectionDirectories(channelRoot) {
|
|
15
|
+
const root = join(channelRoot, "collections");
|
|
16
|
+
return {
|
|
17
|
+
create: async (id) => {
|
|
18
|
+
return createDirectory(root, id);
|
|
19
|
+
},
|
|
20
|
+
exists: async (id) => {
|
|
21
|
+
try {
|
|
22
|
+
await stat(join(root, id));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isNotFound(error)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
recreate: async (id) => {
|
|
33
|
+
await rm(join(root, id), { force: true, recursive: true });
|
|
34
|
+
return createDirectory(root, id);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createCollectionStore } from "../db/collections.js";
|
|
2
|
+
import { getGateState, recordApproval, recordRejection } from "../db/gates.js";
|
|
3
|
+
async function assertCollectionExists(store, collectionId) {
|
|
4
|
+
const collection = await createCollectionStore(store).findById(collectionId);
|
|
5
|
+
if (collection === undefined) {
|
|
6
|
+
throw new Error(`collection does not exist: ${collectionId}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function clockAfterLatestFact(clock, latestTimestamp) {
|
|
10
|
+
const proposedTimestamp = clock.now();
|
|
11
|
+
if (latestTimestamp === undefined || proposedTimestamp.toISOString() > latestTimestamp) {
|
|
12
|
+
return { now: () => proposedTimestamp };
|
|
13
|
+
}
|
|
14
|
+
return { now: () => new Date(new Date(latestTimestamp).getTime() + 1) };
|
|
15
|
+
}
|
|
16
|
+
function createGateOperation(decision, recordDecision) {
|
|
17
|
+
return async (store, input, clock) => {
|
|
18
|
+
await assertCollectionExists(store, input.collectionId);
|
|
19
|
+
const state = await getGateState(store, input.collectionId, input.gate);
|
|
20
|
+
const recorded = state.decision !== decision;
|
|
21
|
+
if (recorded) {
|
|
22
|
+
await recordDecision(store, input, clockAfterLatestFact(clock, state.latestTimestamp));
|
|
23
|
+
}
|
|
24
|
+
return { ...input, recorded };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export const approveCollectionGate = createGateOperation("approved", recordApproval);
|
|
28
|
+
export const rejectCollectionGate = createGateOperation("rejected", recordRejection);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { approvals, collections, rejections, thumbnails } from "./schema.js";
|
|
3
|
+
async function findCollection(store, condition) {
|
|
4
|
+
const rows = await store.db.select().from(collections).where(condition).limit(1);
|
|
5
|
+
return rows[0];
|
|
6
|
+
}
|
|
7
|
+
export async function hasThumbnail(store, collectionId) {
|
|
8
|
+
const rows = await store.db
|
|
9
|
+
.select({ present: thumbnails.collectionId })
|
|
10
|
+
.from(thumbnails)
|
|
11
|
+
.where(eq(thumbnails.collectionId, collectionId))
|
|
12
|
+
.limit(1);
|
|
13
|
+
return rows.length > 0;
|
|
14
|
+
}
|
|
15
|
+
export function createCollectionStore(store) {
|
|
16
|
+
return {
|
|
17
|
+
create: async (collection) => {
|
|
18
|
+
await store.db.insert(collections).values(collection);
|
|
19
|
+
},
|
|
20
|
+
findById: (id) => findCollection(store, eq(collections.id, id)),
|
|
21
|
+
findByTitle: (title) => findCollection(store, eq(collections.title, title)),
|
|
22
|
+
hasDownstreamRecords: async (id) => {
|
|
23
|
+
const [thumbnail, approval, rejection] = await Promise.all([
|
|
24
|
+
hasThumbnail(store, id),
|
|
25
|
+
store.db
|
|
26
|
+
.select({ present: approvals.collectionId })
|
|
27
|
+
.from(approvals)
|
|
28
|
+
.where(eq(approvals.collectionId, id))
|
|
29
|
+
.limit(1),
|
|
30
|
+
store.db
|
|
31
|
+
.select({ present: rejections.collectionId })
|
|
32
|
+
.from(rejections)
|
|
33
|
+
.where(eq(rejections.collectionId, id))
|
|
34
|
+
.limit(1),
|
|
35
|
+
]);
|
|
36
|
+
return thumbnail || approval.length > 0 || rejection.length > 0;
|
|
37
|
+
},
|
|
38
|
+
recreate: async (collection) => {
|
|
39
|
+
await store.db.transaction(async (transaction) => {
|
|
40
|
+
await transaction.delete(collections).where(eq(collections.id, collection.id));
|
|
41
|
+
await transaction.insert(collections).values(collection);
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/db/gates.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { approvals, rejections } from "./schema.js";
|
|
3
|
+
const gateSchema = z.enum(["produce", "publish"]);
|
|
4
|
+
export function parseGate(value) {
|
|
5
|
+
return gateSchema.parse(value);
|
|
6
|
+
}
|
|
7
|
+
async function latestTimestamp(store, collectionId, gate, decision) {
|
|
8
|
+
const query = decision === "approval"
|
|
9
|
+
? "SELECT approved_at AS timestamp FROM approvals WHERE collection_id = ? AND gate = ? ORDER BY approved_at DESC LIMIT 1"
|
|
10
|
+
: "SELECT rejected_at AS timestamp FROM rejections WHERE collection_id = ? AND gate = ? ORDER BY rejected_at DESC LIMIT 1";
|
|
11
|
+
const result = await store.client.execute({ args: [collectionId, gate], sql: query });
|
|
12
|
+
const timestamp = result.rows[0]?.["timestamp"];
|
|
13
|
+
return typeof timestamp === "string" ? timestamp : undefined;
|
|
14
|
+
}
|
|
15
|
+
function decideGate(latestApproval, latestRejection) {
|
|
16
|
+
if (latestRejection === undefined) {
|
|
17
|
+
return latestApproval === undefined ? "pending" : "approved";
|
|
18
|
+
}
|
|
19
|
+
return latestApproval !== undefined && latestApproval > latestRejection ? "approved" : "rejected";
|
|
20
|
+
}
|
|
21
|
+
function latestGateTimestamp(latestApproval, latestRejection) {
|
|
22
|
+
if (latestApproval === undefined) {
|
|
23
|
+
return latestRejection;
|
|
24
|
+
}
|
|
25
|
+
if (latestRejection === undefined || latestApproval >= latestRejection) {
|
|
26
|
+
return latestApproval;
|
|
27
|
+
}
|
|
28
|
+
return latestRejection;
|
|
29
|
+
}
|
|
30
|
+
export async function getGateState(store, collectionId, gate) {
|
|
31
|
+
const [latestApproval, latestRejection] = await Promise.all([
|
|
32
|
+
latestTimestamp(store, collectionId, gate, "approval"),
|
|
33
|
+
latestTimestamp(store, collectionId, gate, "rejection"),
|
|
34
|
+
]);
|
|
35
|
+
return {
|
|
36
|
+
decision: decideGate(latestApproval, latestRejection),
|
|
37
|
+
latestTimestamp: latestGateTimestamp(latestApproval, latestRejection),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function getGateDecision(store, collectionId, gate) {
|
|
41
|
+
return (await getGateState(store, collectionId, gate)).decision;
|
|
42
|
+
}
|
|
43
|
+
async function recordGateFact(store, fact, clock, decision) {
|
|
44
|
+
const gate = parseGate(fact.gate);
|
|
45
|
+
const timestamp = clock.now().toISOString();
|
|
46
|
+
if (decision === "approval") {
|
|
47
|
+
await store.db.insert(approvals).values({
|
|
48
|
+
approvedAt: timestamp,
|
|
49
|
+
collectionId: fact.collectionId,
|
|
50
|
+
gate,
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await store.db.insert(rejections).values({
|
|
55
|
+
collectionId: fact.collectionId,
|
|
56
|
+
gate,
|
|
57
|
+
rejectedAt: timestamp,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function recordApproval(store, fact, clock) {
|
|
61
|
+
await recordGateFact(store, fact, clock, "approval");
|
|
62
|
+
}
|
|
63
|
+
export async function recordRejection(store, fact, clock) {
|
|
64
|
+
await recordGateFact(store, fact, clock, "rejection");
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { createClient } from "@libsql/client";
|
|
5
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
6
|
+
import { migrate } from "drizzle-orm/libsql/migrator";
|
|
7
|
+
import { readMigrationFiles } from "drizzle-orm/migrator";
|
|
8
|
+
import * as schema from "./schema.js";
|
|
9
|
+
const migrationsFolder = resolve(import.meta.dirname, "../../drizzle");
|
|
10
|
+
async function hasPendingMigrations(client) {
|
|
11
|
+
const migrations = readMigrationFiles({ migrationsFolder });
|
|
12
|
+
const latestMigration = migrations.at(-1);
|
|
13
|
+
if (latestMigration === undefined) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const table = await client.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'");
|
|
17
|
+
if (table.rows.length === 0) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
const applied = await client.execute("SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1");
|
|
21
|
+
const latestApplied = applied.rows[0]?.["created_at"];
|
|
22
|
+
return typeof latestApplied !== "number" || latestApplied < latestMigration.folderMillis;
|
|
23
|
+
}
|
|
24
|
+
export async function openLocalStore(channelRoot) {
|
|
25
|
+
const dataDirectory = join(channelRoot, "data");
|
|
26
|
+
const databasePath = join(dataDirectory, "local.db");
|
|
27
|
+
mkdirSync(dataDirectory, { recursive: true });
|
|
28
|
+
const existed = existsSync(databasePath);
|
|
29
|
+
const client = createClient({ url: pathToFileURL(databasePath).href });
|
|
30
|
+
const db = drizzle(client, { schema });
|
|
31
|
+
if (existed && (await hasPendingMigrations(client))) {
|
|
32
|
+
const latestVersion = readMigrationFiles({ migrationsFolder }).at(-1)?.folderMillis;
|
|
33
|
+
if (latestVersion === undefined) {
|
|
34
|
+
throw new Error("pending migration has no version");
|
|
35
|
+
}
|
|
36
|
+
copyFileSync(databasePath, `${databasePath}.bak-${latestVersion}`);
|
|
37
|
+
}
|
|
38
|
+
await migrate(db, { migrationsFolder });
|
|
39
|
+
return {
|
|
40
|
+
client,
|
|
41
|
+
close: async () => client.close(),
|
|
42
|
+
db,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createCollectionStore, hasThumbnail } from "./collections.js";
|
|
2
|
+
import { getGateDecision } from "./gates.js";
|
|
3
|
+
function progressFromFacts(produce, publish, hasThumbnail) {
|
|
4
|
+
if ([produce, publish].includes("rejected")) {
|
|
5
|
+
return { terminated: true };
|
|
6
|
+
}
|
|
7
|
+
if (produce === "pending") {
|
|
8
|
+
return { awaitingApproval: "produce", terminated: false };
|
|
9
|
+
}
|
|
10
|
+
if (!hasThumbnail) {
|
|
11
|
+
return { terminated: false };
|
|
12
|
+
}
|
|
13
|
+
if (publish === "pending") {
|
|
14
|
+
return { awaitingApproval: "publish", terminated: false };
|
|
15
|
+
}
|
|
16
|
+
return { terminated: false };
|
|
17
|
+
}
|
|
18
|
+
export async function deriveCollectionStatus(store, collectionId) {
|
|
19
|
+
const collection = await createCollectionStore(store).findById(collectionId);
|
|
20
|
+
if (collection === undefined) {
|
|
21
|
+
throw new Error("collection does not exist");
|
|
22
|
+
}
|
|
23
|
+
const [produce, publish, thumbnailExists] = await Promise.all([
|
|
24
|
+
getGateDecision(store, collectionId, "produce"),
|
|
25
|
+
getGateDecision(store, collectionId, "publish"),
|
|
26
|
+
hasThumbnail(store, collectionId),
|
|
27
|
+
]);
|
|
28
|
+
return {
|
|
29
|
+
collectionId,
|
|
30
|
+
gates: { produce, publish },
|
|
31
|
+
progress: progressFromFacts(produce, publish, thumbnailExists),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function deriveCollectionProgress(store, collectionId) {
|
|
35
|
+
return (await deriveCollectionStatus(store, collectionId)).progress;
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { check, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
3
|
+
export const collections = sqliteTable("collections", {
|
|
4
|
+
id: text().primaryKey(),
|
|
5
|
+
title: text().notNull().unique(),
|
|
6
|
+
});
|
|
7
|
+
export const thumbnails = sqliteTable("thumbnails", {
|
|
8
|
+
collectionId: text("collection_id")
|
|
9
|
+
.primaryKey()
|
|
10
|
+
.references(() => collections.id),
|
|
11
|
+
path: text().notNull(),
|
|
12
|
+
createdAt: text("created_at").notNull(),
|
|
13
|
+
});
|
|
14
|
+
export const approvals = sqliteTable("approvals", {
|
|
15
|
+
collectionId: text("collection_id")
|
|
16
|
+
.notNull()
|
|
17
|
+
.references(() => collections.id),
|
|
18
|
+
gate: text().notNull(),
|
|
19
|
+
approvedAt: text("approved_at").notNull(),
|
|
20
|
+
}, (table) => [check("approvals_gate", sql `${table.gate} IN ('produce', 'publish')`)]);
|
|
21
|
+
export const rejections = sqliteTable("rejections", {
|
|
22
|
+
collectionId: text("collection_id")
|
|
23
|
+
.notNull()
|
|
24
|
+
.references(() => collections.id),
|
|
25
|
+
gate: text().notNull(),
|
|
26
|
+
rejectedAt: text("rejected_at").notNull(),
|
|
27
|
+
}, (table) => [check("rejections_gate", sql `${table.gate} IN ('produce', 'publish')`)]);
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createCollectionDirectories } from "./collections/directories.js";
|
|
3
|
+
import { approveCollectionGate, rejectCollectionGate } from "./collections/gate-operations.js";
|
|
4
|
+
import { createCollectionStore } from "./db/collections.js";
|
|
5
|
+
import { parseGate } from "./db/gates.js";
|
|
6
|
+
import { openLocalStore } from "./db/local-store.js";
|
|
7
|
+
import { deriveCollectionStatus } from "./db/read-model.js";
|
|
8
|
+
import { serveMcp } from "./mcp.js";
|
|
9
|
+
import { createCollectionStatusTool } from "./tools/collection.status.js";
|
|
10
|
+
import { createPlanCheckTitleTool } from "./tools/plan.checkTitle.js";
|
|
11
|
+
import { createPlanInitTool } from "./tools/plan.init.js";
|
|
12
|
+
import { createProductionYouTubeAuth } from "./youtube/auth.js";
|
|
13
|
+
const systemClock = { now: () => new Date() };
|
|
14
|
+
function requireArguments(arguments_, expected, usage) {
|
|
15
|
+
if (arguments_.length !== expected) {
|
|
16
|
+
throw new Error(`usage: ${usage}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function gateOperationMessage(collectionId, gate, recorded, decision) {
|
|
20
|
+
if (!recorded) {
|
|
21
|
+
return decision === "approval"
|
|
22
|
+
? `既に承認済みです: collection ${collectionId} / gate=${gate}(記録は追加していません)\n`
|
|
23
|
+
: `既に NO-GO 済みです: collection ${collectionId} / gate=${gate}(記録は追加していません)\n`;
|
|
24
|
+
}
|
|
25
|
+
if (decision === "approval") {
|
|
26
|
+
return (`承認を記録しました: collection ${collectionId} / gate=${gate}\n` +
|
|
27
|
+
`Claude Code で collection ${collectionId} の ${gate} 区間を実行してください。\n`);
|
|
28
|
+
}
|
|
29
|
+
return (`NO-GO を記録しました: collection ${collectionId} / gate=${gate}\n` +
|
|
30
|
+
`この collection は ${gate} ゲートで停止します。判断を覆して先へ進める場合は\n` +
|
|
31
|
+
`nyaucast collection ${gate} ${collectionId} を実行してください(NO-GO より後に承認を積むと覆ります)。\n`);
|
|
32
|
+
}
|
|
33
|
+
async function runGateOperation(channelRoot, gate, collectionId, decision) {
|
|
34
|
+
const store = await openLocalStore(channelRoot);
|
|
35
|
+
try {
|
|
36
|
+
const operation = decision === "approval" ? approveCollectionGate : rejectCollectionGate;
|
|
37
|
+
const result = await operation(store, { collectionId, gate }, systemClock);
|
|
38
|
+
process.stdout.write(gateOperationMessage(collectionId, gate, result.recorded, decision));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await store.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function runCollection(arguments_) {
|
|
45
|
+
const subcommand = arguments_[0];
|
|
46
|
+
if (subcommand === "produce" || subcommand === "publish") {
|
|
47
|
+
requireArguments(arguments_, 2, `nyaucast collection ${subcommand} <id>`);
|
|
48
|
+
await runGateOperation(process.cwd(), subcommand, arguments_[1], "approval");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (subcommand === "reject") {
|
|
52
|
+
requireArguments(arguments_, 3, "nyaucast collection reject <gate> <id>");
|
|
53
|
+
await runGateOperation(process.cwd(), parseGate(arguments_[1]), arguments_[2], "rejection");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`unknown collection command: ${String(subcommand)}`);
|
|
57
|
+
}
|
|
58
|
+
async function runMcp() {
|
|
59
|
+
const channelRoot = process.cwd();
|
|
60
|
+
const store = await openLocalStore(channelRoot);
|
|
61
|
+
const collectionStore = createCollectionStore(store);
|
|
62
|
+
const tools = [
|
|
63
|
+
createPlanInitTool({
|
|
64
|
+
collectionDirectories: createCollectionDirectories(channelRoot),
|
|
65
|
+
collectionStore,
|
|
66
|
+
generateCollectionId: randomUUID,
|
|
67
|
+
}),
|
|
68
|
+
createPlanCheckTitleTool({ findCollectionByTitle: collectionStore.findByTitle }),
|
|
69
|
+
createCollectionStatusTool({
|
|
70
|
+
getCollectionStatus: (collectionId) => deriveCollectionStatus(store, collectionId),
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
process.once("exit", () => store.client.close());
|
|
74
|
+
await serveMcp(tools);
|
|
75
|
+
}
|
|
76
|
+
async function runAuth(command, arguments_) {
|
|
77
|
+
// fallow-ignore-next-line code-duplication -- Top-level auth dispatch and collection existence checks have different effects.
|
|
78
|
+
if (command !== "auth")
|
|
79
|
+
throw new Error(`unknown command: ${command}`);
|
|
80
|
+
requireArguments(arguments_, 1, "nyaucast auth <channel>");
|
|
81
|
+
await createProductionYouTubeAuth().authenticate(arguments_[0]);
|
|
82
|
+
}
|
|
83
|
+
async function main() {
|
|
84
|
+
const [command, ...arguments_] = process.argv.slice(2);
|
|
85
|
+
if (command === undefined) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (command === "mcp") {
|
|
89
|
+
requireArguments(arguments_, 0, "nyaucast mcp");
|
|
90
|
+
await runMcp();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (command === "collection") {
|
|
94
|
+
await runCollection(arguments_);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await runAuth(command, arguments_);
|
|
98
|
+
}
|
|
99
|
+
await main();
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
function wireName(name) {
|
|
4
|
+
return name
|
|
5
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
6
|
+
.replaceAll(".", "_")
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
export async function serveMcp(tools) {
|
|
10
|
+
const server = new McpServer({ name: "nyaucast", version: "0.0.2" });
|
|
11
|
+
for (const tool of tools) {
|
|
12
|
+
server.registerTool(wireName(tool.name), {
|
|
13
|
+
description: tool.description,
|
|
14
|
+
inputSchema: tool.inputSchema,
|
|
15
|
+
outputSchema: tool.outputSchema,
|
|
16
|
+
}, async (input) => {
|
|
17
|
+
const result = await tool.handler(input);
|
|
18
|
+
return {
|
|
19
|
+
content: [{ text: JSON.stringify(result), type: "text" }],
|
|
20
|
+
structuredContent: result,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
await server.connect(new StdioServerTransport());
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const gateDecisionSchema = z.enum(["approved", "pending", "rejected"]);
|
|
3
|
+
const inputSchema = z.object({ collectionId: z.string() }).strict();
|
|
4
|
+
const outputSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
collectionId: z.string(),
|
|
7
|
+
gates: z.object({ produce: gateDecisionSchema, publish: gateDecisionSchema }).strict(),
|
|
8
|
+
progress: z
|
|
9
|
+
.object({
|
|
10
|
+
awaitingApproval: z.enum(["produce", "publish"]).optional(),
|
|
11
|
+
terminated: z.boolean(),
|
|
12
|
+
})
|
|
13
|
+
.strict(),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
export function createCollectionStatusTool(dependencies) {
|
|
17
|
+
return {
|
|
18
|
+
description: "Read derived collection progress and current gate decisions.",
|
|
19
|
+
handler: async (input) => {
|
|
20
|
+
const parsed = inputSchema.parse(input);
|
|
21
|
+
return dependencies.getCollectionStatus(parsed.collectionId);
|
|
22
|
+
},
|
|
23
|
+
inputSchema,
|
|
24
|
+
name: "collection.status",
|
|
25
|
+
outputSchema,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const titleSchema = z.string().max(100);
|
|
3
|
+
async function assertTitleAvailable(title, lookup) {
|
|
4
|
+
titleSchema.parse(title);
|
|
5
|
+
if ((await lookup.findCollectionByTitle(title)) !== undefined) {
|
|
6
|
+
throw new Error("collection title is already in use");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const inputSchema = z.object({ title: titleSchema }).strict();
|
|
10
|
+
const outputSchema = z.object({ ok: z.literal(true) }).strict();
|
|
11
|
+
export function createPlanCheckTitleTool(lookup) {
|
|
12
|
+
return {
|
|
13
|
+
description: "Validate that a collection title is within the limit and unused.",
|
|
14
|
+
handler: async (input) => {
|
|
15
|
+
const parsed = inputSchema.parse(input);
|
|
16
|
+
await assertTitleAvailable(parsed.title, lookup);
|
|
17
|
+
return { ok: true };
|
|
18
|
+
},
|
|
19
|
+
inputSchema,
|
|
20
|
+
name: "plan.checkTitle",
|
|
21
|
+
outputSchema,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { titleSchema } from "./plan.checkTitle.js";
|
|
3
|
+
const inputSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
force: z.boolean().optional().default(false),
|
|
6
|
+
title: titleSchema,
|
|
7
|
+
})
|
|
8
|
+
.strict();
|
|
9
|
+
const outputSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
collectionId: z.string(),
|
|
12
|
+
created: z.boolean(),
|
|
13
|
+
dir: z.string(),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
function collectionDirectory(id) {
|
|
17
|
+
return `collections/${id}`;
|
|
18
|
+
}
|
|
19
|
+
async function initializeExistingCollection(existing, force, dependencies) {
|
|
20
|
+
if (!force) {
|
|
21
|
+
return {
|
|
22
|
+
collectionId: existing.id,
|
|
23
|
+
created: false,
|
|
24
|
+
dir: collectionDirectory(existing.id),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (await dependencies.collectionStore.hasDownstreamRecords(existing.id)) {
|
|
28
|
+
throw new Error("collection with downstream records cannot be recreated");
|
|
29
|
+
}
|
|
30
|
+
const dir = await dependencies.collectionDirectories.recreate(existing.id);
|
|
31
|
+
await dependencies.collectionStore.recreate(existing);
|
|
32
|
+
return { collectionId: existing.id, created: true, dir };
|
|
33
|
+
}
|
|
34
|
+
async function initializeNewCollection(title, dependencies) {
|
|
35
|
+
const collectionId = dependencies.generateCollectionId();
|
|
36
|
+
const recordExists = (await dependencies.collectionStore.findById(collectionId)) !== undefined;
|
|
37
|
+
const directoryExists = await dependencies.collectionDirectories.exists(collectionId);
|
|
38
|
+
if (recordExists || directoryExists) {
|
|
39
|
+
throw new Error("generated collection ID already exists");
|
|
40
|
+
}
|
|
41
|
+
await dependencies.collectionStore.create({ id: collectionId, title });
|
|
42
|
+
const dir = await dependencies.collectionDirectories.create(collectionId);
|
|
43
|
+
return { collectionId, created: true, dir };
|
|
44
|
+
}
|
|
45
|
+
export function createPlanInitTool(dependencies) {
|
|
46
|
+
return {
|
|
47
|
+
description: "Initialize a flat collection directory and its local-store record.",
|
|
48
|
+
handler: async (input) => {
|
|
49
|
+
const parsed = inputSchema.parse(input);
|
|
50
|
+
const existing = await dependencies.collectionStore.findByTitle(parsed.title);
|
|
51
|
+
if (existing !== undefined) {
|
|
52
|
+
return initializeExistingCollection(existing, parsed.force, dependencies);
|
|
53
|
+
}
|
|
54
|
+
return initializeNewCollection(parsed.title, dependencies);
|
|
55
|
+
},
|
|
56
|
+
inputSchema,
|
|
57
|
+
name: "plan.init",
|
|
58
|
+
outputSchema,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { OAuth2Client } from "google-auth-library";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { authorizeWithLoopback } from "./oauth-loopback.js";
|
|
9
|
+
const youtubeScopes = [
|
|
10
|
+
"https://www.googleapis.com/auth/youtube",
|
|
11
|
+
"https://www.googleapis.com/auth/youtube.force-ssl",
|
|
12
|
+
"https://www.googleapis.com/auth/yt-analytics.readonly",
|
|
13
|
+
"https://www.googleapis.com/auth/yt-analytics-monetary.readonly",
|
|
14
|
+
];
|
|
15
|
+
const requiredString = z.string().min(1);
|
|
16
|
+
const optionalCredentialToken = z.string().nullable().optional();
|
|
17
|
+
const clientSecretsSchema = z.strictObject({
|
|
18
|
+
installed: z.strictObject({
|
|
19
|
+
// fallow-ignore-next-line code-duplication -- Client-secret validation and MCP input schemas validate different boundaries.
|
|
20
|
+
client_id: requiredString,
|
|
21
|
+
client_secret: requiredString,
|
|
22
|
+
redirect_uris: z.array(requiredString).min(1),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
const storedCredentialsSchema = z.looseObject({
|
|
26
|
+
access_token: optionalCredentialToken,
|
|
27
|
+
expiry_date: z.number().nullable().optional(),
|
|
28
|
+
refresh_token: optionalCredentialToken,
|
|
29
|
+
});
|
|
30
|
+
const newCredentialsSchema = storedCredentialsSchema.extend({
|
|
31
|
+
access_token: requiredString,
|
|
32
|
+
refresh_token: requiredString,
|
|
33
|
+
});
|
|
34
|
+
const refreshedCredentialsSchema = storedCredentialsSchema.extend({
|
|
35
|
+
access_token: requiredString,
|
|
36
|
+
});
|
|
37
|
+
const channelSchema = z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.refine((channel) => channel !== "." && channel !== ".." && !/[/\\]/u.test(channel));
|
|
41
|
+
const credentialPaths = (root, channel) => {
|
|
42
|
+
const slug = channelSchema.parse(channel);
|
|
43
|
+
const directory = join(root, slug);
|
|
44
|
+
return {
|
|
45
|
+
clientSecrets: join(directory, "client_secrets.json"),
|
|
46
|
+
directory,
|
|
47
|
+
token: join(directory, "token.json"),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
class AuthRequiredError extends Error {
|
|
51
|
+
constructor(channel) {
|
|
52
|
+
super(`YouTube 認証が必要です。nyaucast auth ${channel} を実行してください`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const redactFailure = async (failure, operation) => {
|
|
56
|
+
try {
|
|
57
|
+
return await operation();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw failure;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const readCredential = async (path, schema, failure) => {
|
|
64
|
+
return redactFailure(failure, async () => {
|
|
65
|
+
const contents = await readFile(path, "utf8");
|
|
66
|
+
await chmod(path, 0o600);
|
|
67
|
+
return schema.parse(JSON.parse(contents));
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const saveToken = async (paths, credentials) => {
|
|
71
|
+
const temporaryToken = join(paths.directory, `.token-${randomUUID()}.tmp`);
|
|
72
|
+
try {
|
|
73
|
+
// fallow-ignore-next-line code-duplication -- Atomic credential publication is not an OAuth request block.
|
|
74
|
+
await redactFailure(new Error("YouTube credential の保存に失敗しました"), async () => {
|
|
75
|
+
await mkdir(paths.directory, { mode: 0o700, recursive: true });
|
|
76
|
+
await writeFile(temporaryToken, `${JSON.stringify(credentials, undefined, 2)}\n`, {
|
|
77
|
+
flag: "wx",
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
await rename(temporaryToken, paths.token);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await Promise.allSettled([rm(temporaryToken, { force: true })]);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
class YouTubeAuthService {
|
|
89
|
+
#dependencies;
|
|
90
|
+
constructor(dependencies) {
|
|
91
|
+
this.#dependencies = dependencies;
|
|
92
|
+
}
|
|
93
|
+
async authenticate(channel) {
|
|
94
|
+
const paths = credentialPaths(this.#dependencies.credentialRoot, channel);
|
|
95
|
+
const clientSecrets = await this.#loadClientSecrets(paths, channel);
|
|
96
|
+
// fallow-ignore-next-line code-duplication -- Interactive authorization and token refresh have different failure contracts.
|
|
97
|
+
const credentials = await redactFailure(new Error("YouTube 認証に失敗しました。client_secrets.json を確認してください"), async () => {
|
|
98
|
+
const installed = clientSecrets.installed;
|
|
99
|
+
const authorized = await this.#dependencies.authorize({
|
|
100
|
+
clientId: installed.client_id,
|
|
101
|
+
clientSecret: installed.client_secret,
|
|
102
|
+
scopes: [...youtubeScopes],
|
|
103
|
+
});
|
|
104
|
+
return newCredentialsSchema.parse(authorized.credentials);
|
|
105
|
+
});
|
|
106
|
+
await saveToken(paths, credentials);
|
|
107
|
+
}
|
|
108
|
+
async getAccessToken(channel) {
|
|
109
|
+
const loaded = await this.#loadOAuthClient(channel);
|
|
110
|
+
const credentialsBeforeUpdate = { ...loaded.client.credentials };
|
|
111
|
+
const credentials = await this.#validatedUpdatedCredentials(channel, async () => {
|
|
112
|
+
await loaded.client.getAccessToken();
|
|
113
|
+
return loaded.client.credentials;
|
|
114
|
+
});
|
|
115
|
+
if (!isDeepStrictEqual(credentialsBeforeUpdate, loaded.client.credentials)) {
|
|
116
|
+
await saveToken(loaded.paths, credentials);
|
|
117
|
+
}
|
|
118
|
+
return credentials.access_token;
|
|
119
|
+
}
|
|
120
|
+
async refreshAccessToken(channel) {
|
|
121
|
+
return this.#refreshLoaded(channel, await this.#loadOAuthClient(channel));
|
|
122
|
+
}
|
|
123
|
+
async #loadClientSecrets(paths, channel) {
|
|
124
|
+
return readCredential(paths.clientSecrets, clientSecretsSchema, new Error(`client_secrets.json を ~/.config/nyaucast/${channel}/client_secrets.json に配置してください`));
|
|
125
|
+
}
|
|
126
|
+
async #loadOAuthClient(channel) {
|
|
127
|
+
const paths = credentialPaths(this.#dependencies.credentialRoot, channel);
|
|
128
|
+
const [clientSecrets, credentials] = await Promise.all([
|
|
129
|
+
this.#loadClientSecrets(paths, channel),
|
|
130
|
+
readCredential(paths.token, storedCredentialsSchema, new AuthRequiredError(channel)),
|
|
131
|
+
]);
|
|
132
|
+
const installed = clientSecrets.installed;
|
|
133
|
+
const client = this.#dependencies.createOAuthClient({
|
|
134
|
+
clientId: installed.client_id,
|
|
135
|
+
clientSecret: installed.client_secret,
|
|
136
|
+
redirectUri: installed.redirect_uris[0],
|
|
137
|
+
});
|
|
138
|
+
const storedCredentials = credentials;
|
|
139
|
+
client.setCredentials(storedCredentials);
|
|
140
|
+
return { client, paths };
|
|
141
|
+
}
|
|
142
|
+
async #refreshLoaded(channel, loaded) {
|
|
143
|
+
const credentials = await this.#validatedUpdatedCredentials(channel, async () => {
|
|
144
|
+
const response = await loaded.client.refreshAccessToken();
|
|
145
|
+
return response.credentials;
|
|
146
|
+
});
|
|
147
|
+
await saveToken(loaded.paths, credentials);
|
|
148
|
+
return credentials.access_token;
|
|
149
|
+
}
|
|
150
|
+
async #validatedUpdatedCredentials(channel, update) {
|
|
151
|
+
let refreshed;
|
|
152
|
+
try {
|
|
153
|
+
refreshed = refreshedCredentialsSchema.parse(await update());
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new Error(`YouTube credential を更新できません。nyaucast auth ${channel} を実行してください`);
|
|
157
|
+
}
|
|
158
|
+
return refreshed;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export const createYouTubeAuth = (dependencies) => new YouTubeAuthService(dependencies);
|
|
162
|
+
export function createProductionYouTubeAuth() {
|
|
163
|
+
return createYouTubeAuth({
|
|
164
|
+
authorize: authorizeWithLoopback,
|
|
165
|
+
createOAuthClient: (options) => new OAuth2Client(options),
|
|
166
|
+
credentialRoot: join(homedir(), ".config", "nyaucast"),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// fallow-ignore-next-line code-duplication -- Retry limits are unrelated to process timeout constants.
|
|
3
|
+
const maximumAttempts = 3;
|
|
4
|
+
const initialRetryDelayMilliseconds = 1_000;
|
|
5
|
+
const retryableStatuses = new Set([429, 503]);
|
|
6
|
+
const youtubeApiOrigin = "https://youtube.googleapis.com";
|
|
7
|
+
const youtubeUploadApiOrigin = "https://www.googleapis.com";
|
|
8
|
+
const youtubeUploadApiPath = "/upload/youtube/v3/videos";
|
|
9
|
+
// fallow-ignore-next-line code-duplication -- Google API error validation is unrelated to tool input schemas.
|
|
10
|
+
const googleErrorSchema = z.object({
|
|
11
|
+
error: z.object({
|
|
12
|
+
errors: z.array(z.object({ reason: z.string() })),
|
|
13
|
+
}),
|
|
14
|
+
});
|
|
15
|
+
const classifyHttpFailure = async (response) => {
|
|
16
|
+
// fallow-ignore-next-line code-duplication -- Google error decoding is unrelated to generic JSON-RPC routing.
|
|
17
|
+
let body;
|
|
18
|
+
try {
|
|
19
|
+
body = await response.json();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
body = undefined;
|
|
23
|
+
}
|
|
24
|
+
const parsed = googleErrorSchema.safeParse(body);
|
|
25
|
+
return {
|
|
26
|
+
reason: parsed.success ? parsed.data.error.errors[0]?.reason : undefined,
|
|
27
|
+
status: response.status,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const httpError = (failure) => {
|
|
31
|
+
const reason = failure.reason === undefined ? "" : ` (${failure.reason})`;
|
|
32
|
+
return new Error(`YouTube API request failed: HTTP ${failure.status}${reason}`);
|
|
33
|
+
};
|
|
34
|
+
const isRetryableFailure = (failure) => retryableStatuses.has(failure.status) ||
|
|
35
|
+
(failure.status === 403 && failure.reason === "quotaExceeded");
|
|
36
|
+
const selectRecovery = (failure, state, random) => {
|
|
37
|
+
// fallow-ignore-next-line code-duplication -- HTTP recovery selection is unrelated to collection-store waiter routing.
|
|
38
|
+
if (failure.status === 401 && !state.refreshedAfterUnauthorized)
|
|
39
|
+
return { kind: "refresh" };
|
|
40
|
+
if (!isRetryableFailure(failure))
|
|
41
|
+
return { kind: "fail" };
|
|
42
|
+
if (state.retryAttempt >= maximumAttempts - 1)
|
|
43
|
+
return { kind: "fail" };
|
|
44
|
+
const delay = initialRetryDelayMilliseconds * 2 ** state.retryAttempt * (1 + random);
|
|
45
|
+
// fallow-ignore-next-line code-duplication -- Returning retry recovery is unrelated to returning a classified HTTP failure.
|
|
46
|
+
return { delay, kind: "retry" };
|
|
47
|
+
};
|
|
48
|
+
// fallow-ignore-next-line code-duplication -- URL trust validation is unrelated to HTTP failure projection.
|
|
49
|
+
const resolveYouTubeApiUrl = (input) => {
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(input);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`YouTube API request URL must use ${youtubeApiOrigin} or ${youtubeUploadApiOrigin}${youtubeUploadApiPath}`);
|
|
56
|
+
}
|
|
57
|
+
// fallow-ignore-next-line code-duplication -- Origin validation and JSON-RPC object validation have different trust boundaries.
|
|
58
|
+
const isYouTubeApi = url.origin === youtubeApiOrigin;
|
|
59
|
+
const isYouTubeUploadApi = url.origin === youtubeUploadApiOrigin && url.pathname === youtubeUploadApiPath;
|
|
60
|
+
if (!isYouTubeApi && !isYouTubeUploadApi) {
|
|
61
|
+
throw new Error(`YouTube API request URL must use ${youtubeApiOrigin} or ${youtubeUploadApiOrigin}${youtubeUploadApiPath}`);
|
|
62
|
+
}
|
|
63
|
+
return url.toString();
|
|
64
|
+
};
|
|
65
|
+
class RestYouTubeClient {
|
|
66
|
+
#dependencies;
|
|
67
|
+
constructor(dependencies) {
|
|
68
|
+
// fallow-ignore-next-line code-duplication -- The client and auth services only share constructor syntax, not a domain abstraction.
|
|
69
|
+
this.#dependencies = dependencies;
|
|
70
|
+
}
|
|
71
|
+
async request(request) {
|
|
72
|
+
const url = resolveYouTubeApiUrl(request.url);
|
|
73
|
+
const accessToken = await this.#dependencies.auth.getAccessToken(request.channel);
|
|
74
|
+
return this.#execute(request, url, {
|
|
75
|
+
accessToken,
|
|
76
|
+
refreshedAfterUnauthorized: false,
|
|
77
|
+
retryAttempt: 0,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// fallow-ignore-next-line code-duplication -- Retry execution and repository queries only share an async method signature.
|
|
81
|
+
async #execute(request, url, state) {
|
|
82
|
+
const response = await this.#fetch(request, url, state.accessToken);
|
|
83
|
+
if (response.ok)
|
|
84
|
+
return request.schema.parse(await response.json());
|
|
85
|
+
const failure = await classifyHttpFailure(response);
|
|
86
|
+
const recovery = selectRecovery(failure, state, this.#dependencies.random());
|
|
87
|
+
if (recovery.kind === "fail")
|
|
88
|
+
throw httpError(failure);
|
|
89
|
+
if (recovery.kind === "refresh") {
|
|
90
|
+
// fallow-ignore-next-line code-duplication -- Unauthorized recovery and backoff update distinct state fields.
|
|
91
|
+
const accessToken = await this.#dependencies.auth.refreshAccessToken(request.channel);
|
|
92
|
+
return this.#execute(request, url, {
|
|
93
|
+
...state,
|
|
94
|
+
accessToken,
|
|
95
|
+
refreshedAfterUnauthorized: true,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
await this.#dependencies.sleep(recovery.delay);
|
|
99
|
+
return this.#execute(request, url, { ...state, retryAttempt: state.retryAttempt + 1 });
|
|
100
|
+
}
|
|
101
|
+
async #fetch(request, url, accessToken) {
|
|
102
|
+
// fallow-ignore-next-line code-duplication -- Authorization header assembly is specific to the REST boundary.
|
|
103
|
+
const headers = new Headers(request.init?.headers);
|
|
104
|
+
headers.set("authorization", `Bearer ${accessToken}`);
|
|
105
|
+
const init = { ...request.init, headers };
|
|
106
|
+
if (request.body !== undefined)
|
|
107
|
+
init.body = request.body();
|
|
108
|
+
try {
|
|
109
|
+
return await this.#dependencies.fetch(url, init);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new Error("YouTube API request failed at the HTTP boundary");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export const createYouTubeClient = (dependencies) => new RestYouTubeClient(dependencies);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { CodeChallengeMethod, OAuth2Client, } from "google-auth-library";
|
|
3
|
+
import { getAuthCode } from "oauth-callback";
|
|
4
|
+
import open from "open";
|
|
5
|
+
const callbackHostname = "127.0.0.1";
|
|
6
|
+
const callbackPath = "/oauth2callback";
|
|
7
|
+
const callbackPort = 53_682;
|
|
8
|
+
const redirectUri = `http://${callbackHostname}:${callbackPort}${callbackPath}`;
|
|
9
|
+
const requireNonEmpty = (value, failure) => {
|
|
10
|
+
// fallow-ignore-next-line code-duplication -- OAuth value validation is unrelated to collection existence validation.
|
|
11
|
+
if (value === undefined || value.length === 0)
|
|
12
|
+
throw new Error(failure);
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
const requireCallbackCode = (callback, expectedState) => {
|
|
16
|
+
// fallow-ignore-next-line code-duplication -- OAuth state correlation is unrelated to CLI argument-count validation.
|
|
17
|
+
if (callback.state !== expectedState)
|
|
18
|
+
throw new Error("OAuth callback validation failed");
|
|
19
|
+
return requireNonEmpty(callback.code, "OAuth callback validation failed");
|
|
20
|
+
};
|
|
21
|
+
export const createLoopbackAuthorizer = (dependencies) => {
|
|
22
|
+
return async function authorize(options) {
|
|
23
|
+
// fallow-ignore-next-line code-duplication -- Loopback OAuth construction is unrelated to stored-token restoration.
|
|
24
|
+
const client = dependencies.createOAuthClient({
|
|
25
|
+
clientId: options.clientId,
|
|
26
|
+
clientSecret: options.clientSecret,
|
|
27
|
+
redirectUri,
|
|
28
|
+
});
|
|
29
|
+
const generatedPkce = await client.generateCodeVerifierAsync();
|
|
30
|
+
const codeChallenge = requireNonEmpty(generatedPkce.codeChallenge, "OAuth PKCE challenge generation failed");
|
|
31
|
+
const state = dependencies.randomState();
|
|
32
|
+
// fallow-ignore-next-line code-duplication -- OAuth authorization parameters are unrelated to MCP tool wiring.
|
|
33
|
+
const authorizationUrl = client.generateAuthUrl({
|
|
34
|
+
access_type: "offline",
|
|
35
|
+
code_challenge: codeChallenge,
|
|
36
|
+
code_challenge_method: CodeChallengeMethod.S256,
|
|
37
|
+
prompt: "consent",
|
|
38
|
+
redirect_uri: redirectUri,
|
|
39
|
+
scope: options.scopes,
|
|
40
|
+
state,
|
|
41
|
+
});
|
|
42
|
+
// fallow-ignore-next-line code-duplication -- Browser callback acquisition and OAuth code exchange use different protocols.
|
|
43
|
+
const callback = await dependencies.getAuthCode({
|
|
44
|
+
authorizationUrl,
|
|
45
|
+
callbackPath,
|
|
46
|
+
hostname: callbackHostname,
|
|
47
|
+
launch: (url) => dependencies.launch(url),
|
|
48
|
+
port: callbackPort,
|
|
49
|
+
});
|
|
50
|
+
const code = requireCallbackCode(callback, state);
|
|
51
|
+
const response = await client.getToken({
|
|
52
|
+
code,
|
|
53
|
+
codeVerifier: generatedPkce.codeVerifier,
|
|
54
|
+
redirect_uri: redirectUri,
|
|
55
|
+
});
|
|
56
|
+
return { credentials: response.tokens };
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
export const authorizeWithLoopback = createLoopbackAuthorizer({
|
|
60
|
+
createOAuthClient: (options) => new OAuth2Client(options),
|
|
61
|
+
getAuthCode,
|
|
62
|
+
launch: open,
|
|
63
|
+
randomState: () => randomBytes(32).toString("base64url"),
|
|
64
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
CREATE TABLE `approvals` (
|
|
2
|
+
`collection_id` text NOT NULL,
|
|
3
|
+
`gate` text NOT NULL,
|
|
4
|
+
`approved_at` text NOT NULL,
|
|
5
|
+
FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE no action,
|
|
6
|
+
CONSTRAINT "approvals_gate" CHECK("approvals"."gate" IN ('produce', 'publish'))
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
CREATE TABLE `collections` (
|
|
10
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
11
|
+
`title` text NOT NULL
|
|
12
|
+
);
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE UNIQUE INDEX `collections_title_unique` ON `collections` (`title`);--> statement-breakpoint
|
|
15
|
+
CREATE TABLE `rejections` (
|
|
16
|
+
`collection_id` text NOT NULL,
|
|
17
|
+
`gate` text NOT NULL,
|
|
18
|
+
`rejected_at` text NOT NULL,
|
|
19
|
+
FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE no action,
|
|
20
|
+
CONSTRAINT "rejections_gate" CHECK("rejections"."gate" IN ('produce', 'publish'))
|
|
21
|
+
);
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
CREATE TABLE `thumbnails` (
|
|
24
|
+
`collection_id` text PRIMARY KEY NOT NULL,
|
|
25
|
+
`path` text NOT NULL,
|
|
26
|
+
`created_at` text NOT NULL,
|
|
27
|
+
FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE no action
|
|
28
|
+
);
|
|
29
|
+
--> statement-breakpoint
|
|
30
|
+
CREATE TRIGGER `approvals_no_update`
|
|
31
|
+
BEFORE UPDATE ON `approvals`
|
|
32
|
+
BEGIN
|
|
33
|
+
SELECT RAISE(ABORT, 'approvals are append-only');
|
|
34
|
+
END;
|
|
35
|
+
--> statement-breakpoint
|
|
36
|
+
CREATE TRIGGER `approvals_no_delete`
|
|
37
|
+
BEFORE DELETE ON `approvals`
|
|
38
|
+
BEGIN
|
|
39
|
+
SELECT RAISE(ABORT, 'approvals are append-only');
|
|
40
|
+
END;
|
|
41
|
+
--> statement-breakpoint
|
|
42
|
+
CREATE TRIGGER `rejections_no_update`
|
|
43
|
+
BEFORE UPDATE ON `rejections`
|
|
44
|
+
BEGIN
|
|
45
|
+
SELECT RAISE(ABORT, 'rejections are append-only');
|
|
46
|
+
END;
|
|
47
|
+
--> statement-breakpoint
|
|
48
|
+
CREATE TRIGGER `rejections_no_delete`
|
|
49
|
+
BEFORE DELETE ON `rejections`
|
|
50
|
+
BEGIN
|
|
51
|
+
SELECT RAISE(ABORT, 'rejections are append-only');
|
|
52
|
+
END;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "6",
|
|
3
|
+
"dialect": "sqlite",
|
|
4
|
+
"id": "5160fc8e-7bb4-427e-8bc1-6e8fed612371",
|
|
5
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
|
+
"tables": {
|
|
7
|
+
"approvals": {
|
|
8
|
+
"name": "approvals",
|
|
9
|
+
"columns": {
|
|
10
|
+
"collection_id": {
|
|
11
|
+
"name": "collection_id",
|
|
12
|
+
"type": "text",
|
|
13
|
+
"primaryKey": false,
|
|
14
|
+
"notNull": true,
|
|
15
|
+
"autoincrement": false
|
|
16
|
+
},
|
|
17
|
+
"gate": {
|
|
18
|
+
"name": "gate",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true,
|
|
22
|
+
"autoincrement": false
|
|
23
|
+
},
|
|
24
|
+
"approved_at": {
|
|
25
|
+
"name": "approved_at",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true,
|
|
29
|
+
"autoincrement": false
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"indexes": {},
|
|
33
|
+
"foreignKeys": {
|
|
34
|
+
"approvals_collection_id_collections_id_fk": {
|
|
35
|
+
"name": "approvals_collection_id_collections_id_fk",
|
|
36
|
+
"tableFrom": "approvals",
|
|
37
|
+
"tableTo": "collections",
|
|
38
|
+
"columnsFrom": ["collection_id"],
|
|
39
|
+
"columnsTo": ["id"],
|
|
40
|
+
"onDelete": "no action",
|
|
41
|
+
"onUpdate": "no action"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"compositePrimaryKeys": {},
|
|
45
|
+
"uniqueConstraints": {},
|
|
46
|
+
"checkConstraints": {
|
|
47
|
+
"approvals_gate": {
|
|
48
|
+
"name": "approvals_gate",
|
|
49
|
+
"value": "\"approvals\".\"gate\" IN ('produce', 'publish')"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"collections": {
|
|
54
|
+
"name": "collections",
|
|
55
|
+
"columns": {
|
|
56
|
+
"id": {
|
|
57
|
+
"name": "id",
|
|
58
|
+
"type": "text",
|
|
59
|
+
"primaryKey": true,
|
|
60
|
+
"notNull": true,
|
|
61
|
+
"autoincrement": false
|
|
62
|
+
},
|
|
63
|
+
"title": {
|
|
64
|
+
"name": "title",
|
|
65
|
+
"type": "text",
|
|
66
|
+
"primaryKey": false,
|
|
67
|
+
"notNull": true,
|
|
68
|
+
"autoincrement": false
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"indexes": {
|
|
72
|
+
"collections_title_unique": {
|
|
73
|
+
"name": "collections_title_unique",
|
|
74
|
+
"columns": ["title"],
|
|
75
|
+
"isUnique": true
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"foreignKeys": {},
|
|
79
|
+
"compositePrimaryKeys": {},
|
|
80
|
+
"uniqueConstraints": {},
|
|
81
|
+
"checkConstraints": {}
|
|
82
|
+
},
|
|
83
|
+
"rejections": {
|
|
84
|
+
"name": "rejections",
|
|
85
|
+
"columns": {
|
|
86
|
+
"collection_id": {
|
|
87
|
+
"name": "collection_id",
|
|
88
|
+
"type": "text",
|
|
89
|
+
"primaryKey": false,
|
|
90
|
+
"notNull": true,
|
|
91
|
+
"autoincrement": false
|
|
92
|
+
},
|
|
93
|
+
"gate": {
|
|
94
|
+
"name": "gate",
|
|
95
|
+
"type": "text",
|
|
96
|
+
"primaryKey": false,
|
|
97
|
+
"notNull": true,
|
|
98
|
+
"autoincrement": false
|
|
99
|
+
},
|
|
100
|
+
"rejected_at": {
|
|
101
|
+
"name": "rejected_at",
|
|
102
|
+
"type": "text",
|
|
103
|
+
"primaryKey": false,
|
|
104
|
+
"notNull": true,
|
|
105
|
+
"autoincrement": false
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"indexes": {},
|
|
109
|
+
"foreignKeys": {
|
|
110
|
+
"rejections_collection_id_collections_id_fk": {
|
|
111
|
+
"name": "rejections_collection_id_collections_id_fk",
|
|
112
|
+
"tableFrom": "rejections",
|
|
113
|
+
"tableTo": "collections",
|
|
114
|
+
"columnsFrom": ["collection_id"],
|
|
115
|
+
"columnsTo": ["id"],
|
|
116
|
+
"onDelete": "no action",
|
|
117
|
+
"onUpdate": "no action"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"compositePrimaryKeys": {},
|
|
121
|
+
"uniqueConstraints": {},
|
|
122
|
+
"checkConstraints": {
|
|
123
|
+
"rejections_gate": {
|
|
124
|
+
"name": "rejections_gate",
|
|
125
|
+
"value": "\"rejections\".\"gate\" IN ('produce', 'publish')"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"thumbnails": {
|
|
130
|
+
"name": "thumbnails",
|
|
131
|
+
"columns": {
|
|
132
|
+
"collection_id": {
|
|
133
|
+
"name": "collection_id",
|
|
134
|
+
"type": "text",
|
|
135
|
+
"primaryKey": true,
|
|
136
|
+
"notNull": true,
|
|
137
|
+
"autoincrement": false
|
|
138
|
+
},
|
|
139
|
+
"path": {
|
|
140
|
+
"name": "path",
|
|
141
|
+
"type": "text",
|
|
142
|
+
"primaryKey": false,
|
|
143
|
+
"notNull": true,
|
|
144
|
+
"autoincrement": false
|
|
145
|
+
},
|
|
146
|
+
"created_at": {
|
|
147
|
+
"name": "created_at",
|
|
148
|
+
"type": "text",
|
|
149
|
+
"primaryKey": false,
|
|
150
|
+
"notNull": true,
|
|
151
|
+
"autoincrement": false
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"indexes": {},
|
|
155
|
+
"foreignKeys": {
|
|
156
|
+
"thumbnails_collection_id_collections_id_fk": {
|
|
157
|
+
"name": "thumbnails_collection_id_collections_id_fk",
|
|
158
|
+
"tableFrom": "thumbnails",
|
|
159
|
+
"tableTo": "collections",
|
|
160
|
+
"columnsFrom": ["collection_id"],
|
|
161
|
+
"columnsTo": ["id"],
|
|
162
|
+
"onDelete": "no action",
|
|
163
|
+
"onUpdate": "no action"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"compositePrimaryKeys": {},
|
|
167
|
+
"uniqueConstraints": {},
|
|
168
|
+
"checkConstraints": {}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"views": {},
|
|
172
|
+
"enums": {},
|
|
173
|
+
"_meta": {
|
|
174
|
+
"schemas": {},
|
|
175
|
+
"tables": {},
|
|
176
|
+
"columns": {}
|
|
177
|
+
},
|
|
178
|
+
"internal": {
|
|
179
|
+
"indexes": {}
|
|
180
|
+
}
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nyaucast",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "YouTube channel operations toolkit",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/daiki-beppu/nyaucast.git"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"nyaucast": "bin/nyaucast.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"bin",
|
|
15
|
+
"drizzle"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"imports": {
|
|
19
|
+
"#nyaucast-entry": {
|
|
20
|
+
"nyaucast-source": "./src/index.ts",
|
|
21
|
+
"types": "./src/index.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@libsql/client": "0.17.4",
|
|
30
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
31
|
+
"drizzle-orm": "0.45.2",
|
|
32
|
+
"google-auth-library": "11.0.2",
|
|
33
|
+
"oauth-callback": "2.2.0",
|
|
34
|
+
"open": "11.0.2",
|
|
35
|
+
"zod": "4.4.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@stryker-mutator/core": "10.0.0",
|
|
39
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
40
|
+
"@types/node": "24.13.3",
|
|
41
|
+
"drizzle-kit": "0.31.10",
|
|
42
|
+
"fallow": "3.18.0",
|
|
43
|
+
"github-actionlint": "1.7.12",
|
|
44
|
+
"typescript": "7.0.2",
|
|
45
|
+
"vite-plus": "0.3.0",
|
|
46
|
+
"yaml": "2.9.0"
|
|
47
|
+
},
|
|
48
|
+
"devEngines": {
|
|
49
|
+
"runtime": {
|
|
50
|
+
"name": "node",
|
|
51
|
+
"version": "24.x"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22.14.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"check": "pnpm run lockfile:check && vp check && pnpm run actions:check && vp test run && pnpm run fallow",
|
|
59
|
+
"lockfile:check": "pnpm install --frozen-lockfile --lockfile-only",
|
|
60
|
+
"actions:check": "github-actionlint",
|
|
61
|
+
"fallow": "fallow --no-cache --fail-on-issues",
|
|
62
|
+
"mutation": "pnpm exec stryker run",
|
|
63
|
+
"db:generate": "drizzle-kit generate"
|
|
64
|
+
}
|
|
6
65
|
}
|