gh-as-db 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/core/indexer.d.ts +11 -0
- package/dist/core/indexer.js +52 -0
- package/dist/core/types.d.ts +49 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/infrastructure/cache-provider.d.ts +17 -0
- package/dist/infrastructure/cache-provider.js +23 -0
- package/dist/infrastructure/github-storage.d.ts +12 -0
- package/dist/infrastructure/github-storage.js +106 -0
- package/dist/ui/cli/commands.d.ts +3 -0
- package/dist/ui/cli/commands.js +108 -0
- package/dist/ui/cli/index.d.ts +2 -0
- package/dist/ui/cli/index.js +30 -0
- package/dist/ui/collection.d.ts +18 -0
- package/dist/ui/collection.js +236 -0
- package/dist/ui/github-db.d.ts +11 -0
- package/dist/ui/github-db.js +25 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tommy Hansen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# gh-as-db
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/gh-as-db)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
Use a private GitHub repository as a database for your application. `gh-as-db` provides a familiar database-like interface (CRUD, filtering, sorting, pagination) while leveraging GitHub's infrastructure for versioned data storage.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- 📂 **GitHub-Backed**: Your data lives in JSON files within a GitHub repository.
|
|
11
|
+
- 🔐 **Secure**: Designed for private repositories using Personal Access Tokens (PAT).
|
|
12
|
+
- 🚀 **Performance**: Built-in in-memory caching and auto-indexing for fast local queries.
|
|
13
|
+
- 🛡️ **Concurrency**: Optimistic locking using Git SHAs to prevent data loss.
|
|
14
|
+
- 🧩 **Middleware**: Extensible hooks for data validation or transformation.
|
|
15
|
+
- ⌨️ **CLI Tool**: Initialize and manage your "database" from the terminal.
|
|
16
|
+
- 📦 **TypeScript**: Fully typed for a great developer experience.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install gh-as-db
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { GitHubDB } from 'gh-as-db';
|
|
28
|
+
|
|
29
|
+
interface User {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
email: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const db = new GitHubDB({
|
|
36
|
+
accessToken: process.env.GITHUB_TOKEN,
|
|
37
|
+
owner: 'your-username',
|
|
38
|
+
repo: 'my-data-repo',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Access the 'users' collection
|
|
42
|
+
const users = db.collection<User>('users');
|
|
43
|
+
|
|
44
|
+
// Create a new user
|
|
45
|
+
await users.create({
|
|
46
|
+
id: '1',
|
|
47
|
+
name: 'John Doe',
|
|
48
|
+
email: 'john@example.com'
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Find a user by ID
|
|
52
|
+
const user = await users.findById('1');
|
|
53
|
+
console.log(user?.name); // 'John Doe'
|
|
54
|
+
|
|
55
|
+
// Query with filters
|
|
56
|
+
const results = await users.find({
|
|
57
|
+
filters: [
|
|
58
|
+
{ field: 'name', operator: 'eq', value: 'John Doe' }
|
|
59
|
+
]
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API Reference
|
|
64
|
+
|
|
65
|
+
### `GitHubDB`
|
|
66
|
+
|
|
67
|
+
The entry point for linking to your GitHub repository.
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const db = new GitHubDB({
|
|
71
|
+
accessToken: string; // GitHub PAT with 'repo' scope
|
|
72
|
+
owner: string; // Repository owner
|
|
73
|
+
repo: string; // Repository name
|
|
74
|
+
branch?: string; // Optional: branch to use (defaults to 'main')
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `Collection<T>`
|
|
79
|
+
|
|
80
|
+
Methods for interacting with your data collections (JSON files).
|
|
81
|
+
|
|
82
|
+
#### `create(item: T): Promise<T>`
|
|
83
|
+
Inserts a new item. If the file doesn't exist, it creates it.
|
|
84
|
+
|
|
85
|
+
#### `find(options?: QueryOptions<T> | ((item: T) => boolean)): Promise<T[]>`
|
|
86
|
+
Fetches items based on a query object or a predicate function.
|
|
87
|
+
|
|
88
|
+
#### `findById(id: string): Promise<T | null>`
|
|
89
|
+
Helper to find a single item by its `id` field.
|
|
90
|
+
|
|
91
|
+
#### `update(id: string, updates: Partial<T>): Promise<T>`
|
|
92
|
+
Updates an existing item. Throws if the item is not found.
|
|
93
|
+
|
|
94
|
+
#### `delete(id: string): Promise<void>`
|
|
95
|
+
Removes an item by its ID.
|
|
96
|
+
|
|
97
|
+
### Querying
|
|
98
|
+
|
|
99
|
+
`gh-as-db` supports advanced querying including filtering, sorting, and pagination.
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
const items = await users.find({
|
|
103
|
+
filters: [
|
|
104
|
+
{ field: 'age', operator: 'gte', value: 18 },
|
|
105
|
+
{ field: 'status', operator: 'eq', value: 'active' }
|
|
106
|
+
],
|
|
107
|
+
sort: [
|
|
108
|
+
{ field: 'name', order: 'asc' }
|
|
109
|
+
],
|
|
110
|
+
pagination: {
|
|
111
|
+
limit: 10,
|
|
112
|
+
offset: 0
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Supported Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`.
|
|
118
|
+
|
|
119
|
+
### Middleware
|
|
120
|
+
|
|
121
|
+
You can attach middleware to intercept operations.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const users = db.collection<User>('users', {
|
|
125
|
+
middleware: [{
|
|
126
|
+
beforeSave: async (item, context) => {
|
|
127
|
+
console.log(`Saving to ${context.collection}...`);
|
|
128
|
+
return { ...item, updatedAt: new Date().toISOString() };
|
|
129
|
+
}
|
|
130
|
+
}]
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## CLI Usage
|
|
135
|
+
|
|
136
|
+
`gh-as-db` comes with a CLI tool to help you manage your repository.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# Initialize a new repository
|
|
140
|
+
npx gh-as-db init
|
|
141
|
+
|
|
142
|
+
# List all collections
|
|
143
|
+
npx gh-as-db list
|
|
144
|
+
|
|
145
|
+
# Inspect a specific collection
|
|
146
|
+
npx gh-as-db inspect <collection-name>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Why gh-as-db?
|
|
150
|
+
|
|
151
|
+
For small projects, side-projects, or internal tools, setting up a database server (PostgreSQL, MongoDB) is often overkill. `gh-as-db` gives you:
|
|
152
|
+
1. **Zero Cost**: GitHub's free tier for private repos is enough for many use cases.
|
|
153
|
+
2. **Versioned Data**: Every change is a commit. You can see history and revert easily.
|
|
154
|
+
3. **Collaboration**: Use GitHub's own UI to edit data in a pinch.
|
|
155
|
+
|
|
156
|
+
## Performance
|
|
157
|
+
|
|
158
|
+
- **Caching**: Data is fetched once and cached in memory. Subsequent reads are near-instant.
|
|
159
|
+
- **Indexing**: Automatic in-memory indexing on all fields makes querying fast even as data grows.
|
|
160
|
+
- **Optimistic Concurrency**: Uses Git SHAs to ensure that you don't overwrite changes made by another client.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Schema } from "./types.js";
|
|
2
|
+
export declare class Indexer<T extends Schema> {
|
|
3
|
+
private indexes;
|
|
4
|
+
build(items: T[], fields: (keyof T)[]): void;
|
|
5
|
+
query(field: keyof T, value: any): T[] | null;
|
|
6
|
+
add(item: T): void;
|
|
7
|
+
remove(item: T): void;
|
|
8
|
+
update(oldItem: T, newItem: T): void;
|
|
9
|
+
clear(): void;
|
|
10
|
+
hasIndex(field: keyof T): boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export class Indexer {
|
|
2
|
+
indexes = new Map();
|
|
3
|
+
build(items, fields) {
|
|
4
|
+
this.indexes.clear();
|
|
5
|
+
for (const field of fields) {
|
|
6
|
+
const fieldIndex = new Map();
|
|
7
|
+
for (const item of items) {
|
|
8
|
+
const val = item[field];
|
|
9
|
+
if (!fieldIndex.has(val)) {
|
|
10
|
+
fieldIndex.set(val, new Set());
|
|
11
|
+
}
|
|
12
|
+
fieldIndex.get(val).add(item);
|
|
13
|
+
}
|
|
14
|
+
this.indexes.set(field, fieldIndex);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
query(field, value) {
|
|
18
|
+
const fieldIndex = this.indexes.get(field);
|
|
19
|
+
if (!fieldIndex)
|
|
20
|
+
return null;
|
|
21
|
+
const matches = fieldIndex.get(value);
|
|
22
|
+
return matches ? Array.from(matches) : [];
|
|
23
|
+
}
|
|
24
|
+
add(item) {
|
|
25
|
+
for (const [field, fieldIndex] of this.indexes) {
|
|
26
|
+
const val = item[field];
|
|
27
|
+
if (!fieldIndex.has(val)) {
|
|
28
|
+
fieldIndex.set(val, new Set());
|
|
29
|
+
}
|
|
30
|
+
fieldIndex.get(val).add(item);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
remove(item) {
|
|
34
|
+
for (const [field, fieldIndex] of this.indexes) {
|
|
35
|
+
const val = item[field];
|
|
36
|
+
const matches = fieldIndex.get(val);
|
|
37
|
+
if (matches) {
|
|
38
|
+
matches.delete(item);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
update(oldItem, newItem) {
|
|
43
|
+
this.remove(oldItem);
|
|
44
|
+
this.add(newItem);
|
|
45
|
+
}
|
|
46
|
+
clear() {
|
|
47
|
+
this.indexes.clear();
|
|
48
|
+
}
|
|
49
|
+
hasIndex(field) {
|
|
50
|
+
return this.indexes.has(field);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface GitHubDBConfig {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
}
|
|
6
|
+
export type Schema = Record<string, any>;
|
|
7
|
+
export type MiddlewareOperation = "create" | "update" | "read" | "delete";
|
|
8
|
+
export interface MiddlewareContext {
|
|
9
|
+
collection: string;
|
|
10
|
+
operation: MiddlewareOperation;
|
|
11
|
+
}
|
|
12
|
+
export interface Middleware<T extends Schema> {
|
|
13
|
+
beforeSave?: (item: T, context: MiddlewareContext) => Promise<T> | T;
|
|
14
|
+
afterRead?: (item: T, context: MiddlewareContext) => Promise<T> | T;
|
|
15
|
+
}
|
|
16
|
+
export type FilterOperator = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "in";
|
|
17
|
+
export interface FilterPredicate<T> {
|
|
18
|
+
field: keyof T;
|
|
19
|
+
operator: FilterOperator;
|
|
20
|
+
value: any;
|
|
21
|
+
}
|
|
22
|
+
export type SortOrder = "asc" | "desc";
|
|
23
|
+
export interface SortOptions<T> {
|
|
24
|
+
field: keyof T;
|
|
25
|
+
order: SortOrder;
|
|
26
|
+
}
|
|
27
|
+
export interface PaginationOptions {
|
|
28
|
+
limit?: number;
|
|
29
|
+
offset?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface QueryOptions<T> {
|
|
32
|
+
filters?: FilterPredicate<T>[];
|
|
33
|
+
sort?: SortOptions<T>[];
|
|
34
|
+
pagination?: PaginationOptions;
|
|
35
|
+
}
|
|
36
|
+
export declare class ConcurrencyError extends Error {
|
|
37
|
+
readonly path: string;
|
|
38
|
+
constructor(path: string);
|
|
39
|
+
}
|
|
40
|
+
export interface StorageResponse<T> {
|
|
41
|
+
data: T;
|
|
42
|
+
sha: string;
|
|
43
|
+
}
|
|
44
|
+
export interface IStorageProvider {
|
|
45
|
+
testConnection(): Promise<boolean>;
|
|
46
|
+
exists(path: string): Promise<boolean>;
|
|
47
|
+
readJson<T>(path: string): Promise<StorageResponse<T>>;
|
|
48
|
+
writeJson<T>(path: string, content: T, message: string, sha?: string): Promise<string>;
|
|
49
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const version = "0.1.0";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const version = "0.1.0";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
data: T;
|
|
3
|
+
sha: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ICacheProvider {
|
|
6
|
+
get<T>(key: string): CacheEntry<T> | null;
|
|
7
|
+
set<T>(key: string, value: CacheEntry<T>, ttl?: number): void;
|
|
8
|
+
delete(key: string): void;
|
|
9
|
+
clear(): void;
|
|
10
|
+
}
|
|
11
|
+
export declare class MemoryCacheProvider implements ICacheProvider {
|
|
12
|
+
private cache;
|
|
13
|
+
get<T>(key: string): CacheEntry<T> | null;
|
|
14
|
+
set<T>(key: string, value: CacheEntry<T>, ttl?: number): void;
|
|
15
|
+
delete(key: string): void;
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class MemoryCacheProvider {
|
|
2
|
+
cache = new Map();
|
|
3
|
+
get(key) {
|
|
4
|
+
const entry = this.cache.get(key);
|
|
5
|
+
if (!entry)
|
|
6
|
+
return null;
|
|
7
|
+
if (entry.expiry !== null && Date.now() > entry.expiry) {
|
|
8
|
+
this.cache.delete(key);
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return entry.data;
|
|
12
|
+
}
|
|
13
|
+
set(key, value, ttl) {
|
|
14
|
+
const expiry = ttl ? Date.now() + ttl : null;
|
|
15
|
+
this.cache.set(key, { data: value, expiry });
|
|
16
|
+
}
|
|
17
|
+
delete(key) {
|
|
18
|
+
this.cache.delete(key);
|
|
19
|
+
}
|
|
20
|
+
clear() {
|
|
21
|
+
this.cache.clear();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { GitHubDBConfig, IStorageProvider, StorageResponse } from "../core/types.js";
|
|
2
|
+
import { ICacheProvider } from "./cache-provider.js";
|
|
3
|
+
export declare class GitHubStorageProvider implements IStorageProvider {
|
|
4
|
+
private config;
|
|
5
|
+
private octokit;
|
|
6
|
+
private cache;
|
|
7
|
+
constructor(config: GitHubDBConfig, cache?: ICacheProvider);
|
|
8
|
+
testConnection(): Promise<boolean>;
|
|
9
|
+
exists(path: string): Promise<boolean>;
|
|
10
|
+
readJson<T>(path: string): Promise<StorageResponse<T>>;
|
|
11
|
+
writeJson<T>(path: string, content: T, message: string, sha?: string): Promise<string>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Octokit } from "@octokit/rest";
|
|
2
|
+
import { ConcurrencyError, } from "../core/types.js";
|
|
3
|
+
import { MemoryCacheProvider } from "./cache-provider.js";
|
|
4
|
+
export class GitHubStorageProvider {
|
|
5
|
+
config;
|
|
6
|
+
octokit;
|
|
7
|
+
cache;
|
|
8
|
+
constructor(config, cache) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.octokit = new Octokit({
|
|
11
|
+
auth: config.accessToken,
|
|
12
|
+
});
|
|
13
|
+
this.cache = cache || new MemoryCacheProvider();
|
|
14
|
+
}
|
|
15
|
+
async testConnection() {
|
|
16
|
+
try {
|
|
17
|
+
await this.octokit.repos.get({
|
|
18
|
+
owner: this.config.owner,
|
|
19
|
+
repo: this.config.repo,
|
|
20
|
+
});
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async exists(path) {
|
|
28
|
+
try {
|
|
29
|
+
await this.octokit.repos.getContent({
|
|
30
|
+
owner: this.config.owner,
|
|
31
|
+
repo: this.config.repo,
|
|
32
|
+
path,
|
|
33
|
+
});
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.status === 404) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async readJson(path) {
|
|
44
|
+
const cached = this.cache.get(path);
|
|
45
|
+
if (cached) {
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
const response = await this.octokit.repos.getContent({
|
|
49
|
+
owner: this.config.owner,
|
|
50
|
+
repo: this.config.repo,
|
|
51
|
+
path,
|
|
52
|
+
});
|
|
53
|
+
if (Array.isArray(response.data)) {
|
|
54
|
+
throw new Error("Path is a directory, not a file");
|
|
55
|
+
}
|
|
56
|
+
if (!("content" in response.data) || !("sha" in response.data)) {
|
|
57
|
+
throw new Error("No content or SHA in response");
|
|
58
|
+
}
|
|
59
|
+
const content = Buffer.from(response.data.content, "base64").toString("utf-8");
|
|
60
|
+
const result = {
|
|
61
|
+
data: JSON.parse(content),
|
|
62
|
+
sha: response.data.sha,
|
|
63
|
+
};
|
|
64
|
+
this.cache.set(path, result);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
async writeJson(path, content, message, sha) {
|
|
68
|
+
let internalSha = sha;
|
|
69
|
+
if (!internalSha) {
|
|
70
|
+
try {
|
|
71
|
+
const existing = await this.octokit.repos.getContent({
|
|
72
|
+
owner: this.config.owner,
|
|
73
|
+
repo: this.config.repo,
|
|
74
|
+
path,
|
|
75
|
+
});
|
|
76
|
+
if (!Array.isArray(existing.data) && "sha" in existing.data) {
|
|
77
|
+
internalSha = existing.data.sha;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.status !== 404) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const response = await this.octokit.repos.createOrUpdateFileContents({
|
|
88
|
+
owner: this.config.owner,
|
|
89
|
+
repo: this.config.repo,
|
|
90
|
+
path,
|
|
91
|
+
message,
|
|
92
|
+
content: Buffer.from(JSON.stringify(content, null, 2)).toString("base64"),
|
|
93
|
+
sha: internalSha,
|
|
94
|
+
});
|
|
95
|
+
const newSha = response.data.content?.sha || "";
|
|
96
|
+
this.cache.delete(path);
|
|
97
|
+
return newSha;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.status === 409) {
|
|
101
|
+
throw new ConcurrencyError(path);
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import enquirer from "enquirer";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { Octokit } from "@octokit/rest";
|
|
4
|
+
import { GitHubStorageProvider } from "../../infrastructure/github-storage.js";
|
|
5
|
+
export async function initCommand() {
|
|
6
|
+
console.log(chalk.yellow("\n--- Database Initialization ---\n"));
|
|
7
|
+
try {
|
|
8
|
+
const questions = [
|
|
9
|
+
{
|
|
10
|
+
type: "input",
|
|
11
|
+
name: "owner",
|
|
12
|
+
message: "GitHub Repository Owner:",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
type: "input",
|
|
16
|
+
name: "repo",
|
|
17
|
+
message: "GitHub Repository Name:",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: "password",
|
|
21
|
+
name: "accessToken",
|
|
22
|
+
message: "GitHub Personal Access Token:",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
const answers = await enquirer.prompt(questions);
|
|
26
|
+
const storage = new GitHubStorageProvider({
|
|
27
|
+
owner: answers.owner,
|
|
28
|
+
repo: answers.repo,
|
|
29
|
+
accessToken: answers.accessToken,
|
|
30
|
+
});
|
|
31
|
+
process.stdout.write(chalk.blue("Verifying connection... "));
|
|
32
|
+
const success = await storage.testConnection();
|
|
33
|
+
if (success) {
|
|
34
|
+
console.log(chalk.green("Success!"));
|
|
35
|
+
console.log(chalk.dim("\nYou can now use these credentials in your application configuration."));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.log(chalk.red("Failed."));
|
|
39
|
+
console.log(chalk.red("Please check your token permissions and repository details."));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error(chalk.red("\nInitialization cancelled or failed."));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function listCollectionsCommand() {
|
|
47
|
+
console.log(chalk.yellow("\n--- Collections List ---\n"));
|
|
48
|
+
const owner = process.env.GH_DB_OWNER;
|
|
49
|
+
const repo = process.env.GH_DB_REPO;
|
|
50
|
+
const auth = process.env.GH_DB_TOKEN;
|
|
51
|
+
if (!owner || !repo || !auth) {
|
|
52
|
+
console.log(chalk.red("Error: Environment variables GH_DB_OWNER, GH_DB_REPO, and GH_DB_TOKEN must be set."));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const octokit = new Octokit({ auth });
|
|
56
|
+
try {
|
|
57
|
+
const { data } = await octokit.repos.getContent({
|
|
58
|
+
owner,
|
|
59
|
+
repo,
|
|
60
|
+
path: "",
|
|
61
|
+
});
|
|
62
|
+
if (Array.isArray(data)) {
|
|
63
|
+
const collections = data
|
|
64
|
+
.filter((item) => item.name.endsWith(".json"))
|
|
65
|
+
.map((item) => item.name.replace(".json", ""));
|
|
66
|
+
if (collections.length === 0) {
|
|
67
|
+
console.log(chalk.dim("No collections found in the repository."));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
collections.forEach((name) => {
|
|
71
|
+
console.log(chalk.cyan(` • ${name}`));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error(chalk.red(`Error fetching collections: ${error.message}`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function inspectCollectionCommand(name) {
|
|
81
|
+
console.log(chalk.yellow(`\n--- Inspecting Collection: ${name} ---\n`));
|
|
82
|
+
const owner = process.env.GH_DB_OWNER;
|
|
83
|
+
const repo = process.env.GH_DB_REPO;
|
|
84
|
+
const auth = process.env.GH_DB_TOKEN;
|
|
85
|
+
if (!owner || !repo || !auth) {
|
|
86
|
+
console.log(chalk.red("Error: Environment variables GH_DB_OWNER, GH_DB_REPO, and GH_DB_TOKEN must be set."));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const storage = new GitHubStorageProvider({
|
|
90
|
+
owner,
|
|
91
|
+
repo,
|
|
92
|
+
accessToken: auth,
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const path = `${name}.json`;
|
|
96
|
+
if (!(await storage.exists(path))) {
|
|
97
|
+
console.log(chalk.red(`Collection "${name}" does not exist.`));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const response = await storage.readJson(path);
|
|
101
|
+
console.log(JSON.stringify(response.data, null, 2));
|
|
102
|
+
console.log(chalk.dim(`\nTotal records: ${response.data.length}`));
|
|
103
|
+
console.log(chalk.dim(`Current SHA: ${response.sha}`));
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error(chalk.red(`Error inspecting collection: ${error.message}`));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import boxen from "boxen";
|
|
5
|
+
import { initCommand, listCollectionsCommand, inspectCollectionCommand, } from "./commands.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
console.log(boxen(chalk.bold.blue("gh-as-db CLI"), {
|
|
8
|
+
padding: 1,
|
|
9
|
+
margin: 1,
|
|
10
|
+
borderStyle: "double",
|
|
11
|
+
borderColor: "blue",
|
|
12
|
+
}));
|
|
13
|
+
program
|
|
14
|
+
.name("gh-as-db")
|
|
15
|
+
.description("CLI to manage your GitHub-based database")
|
|
16
|
+
.version("0.1.0");
|
|
17
|
+
program
|
|
18
|
+
.command("init")
|
|
19
|
+
.description("Initialize connection and verify repository")
|
|
20
|
+
.action(initCommand);
|
|
21
|
+
program
|
|
22
|
+
.command("list")
|
|
23
|
+
.alias("ls")
|
|
24
|
+
.description("List all collections in the database")
|
|
25
|
+
.action(listCollectionsCommand);
|
|
26
|
+
program
|
|
27
|
+
.command("inspect <collection>")
|
|
28
|
+
.description("Inspect the contents of a collection")
|
|
29
|
+
.action(inspectCollectionCommand);
|
|
30
|
+
program.parse();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { IStorageProvider, Middleware, QueryOptions, Schema } from "../core/types.js";
|
|
2
|
+
export declare class Collection<T extends Schema> {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
private readonly storage;
|
|
5
|
+
private readonly middleware;
|
|
6
|
+
private lastSha;
|
|
7
|
+
private indexer;
|
|
8
|
+
private items;
|
|
9
|
+
private dataLoaded;
|
|
10
|
+
constructor(name: string, storage: IStorageProvider, middleware?: Middleware<T>[]);
|
|
11
|
+
private get path();
|
|
12
|
+
create(item: T): Promise<T>;
|
|
13
|
+
find(queryOrPredicate?: ((item: T) => boolean) | QueryOptions<T>): Promise<T[]>;
|
|
14
|
+
private applyQueryOptions;
|
|
15
|
+
findById(id: string): Promise<T | null>;
|
|
16
|
+
update(id: string, updates: Partial<T>): Promise<T>;
|
|
17
|
+
delete(id: string): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { ConcurrencyError, } from "../core/types.js";
|
|
2
|
+
import { Indexer } from "../core/indexer.js";
|
|
3
|
+
export class Collection {
|
|
4
|
+
name;
|
|
5
|
+
storage;
|
|
6
|
+
middleware;
|
|
7
|
+
lastSha;
|
|
8
|
+
indexer = new Indexer();
|
|
9
|
+
items = [];
|
|
10
|
+
dataLoaded = false;
|
|
11
|
+
constructor(name, storage, middleware = []) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.storage = storage;
|
|
14
|
+
this.middleware = middleware;
|
|
15
|
+
}
|
|
16
|
+
get path() {
|
|
17
|
+
return `${this.name}.json`;
|
|
18
|
+
}
|
|
19
|
+
async create(item) {
|
|
20
|
+
let finalItem = item;
|
|
21
|
+
const context = {
|
|
22
|
+
collection: this.name,
|
|
23
|
+
operation: "create",
|
|
24
|
+
};
|
|
25
|
+
for (const mw of this.middleware) {
|
|
26
|
+
if (mw.beforeSave) {
|
|
27
|
+
finalItem = await mw.beforeSave(finalItem, context);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let items = [];
|
|
31
|
+
if (this.dataLoaded) {
|
|
32
|
+
items = [...this.items];
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
try {
|
|
36
|
+
if (await this.storage.exists(this.path)) {
|
|
37
|
+
const response = await this.storage.readJson(this.path);
|
|
38
|
+
items = response.data;
|
|
39
|
+
this.lastSha = response.sha;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// If file doesn't exist, start with empty array
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
items.push(finalItem);
|
|
47
|
+
try {
|
|
48
|
+
this.lastSha = await this.storage.writeJson(this.path, items, `Add item to ${this.name}`, this.lastSha);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof ConcurrencyError) {
|
|
52
|
+
this.dataLoaded = false;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
// Update in-memory state if already loaded
|
|
57
|
+
if (this.dataLoaded) {
|
|
58
|
+
this.items = items;
|
|
59
|
+
this.indexer.add(finalItem);
|
|
60
|
+
}
|
|
61
|
+
return finalItem;
|
|
62
|
+
}
|
|
63
|
+
async find(queryOrPredicate) {
|
|
64
|
+
if (!(await this.storage.exists(this.path))) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
let items;
|
|
68
|
+
const context = {
|
|
69
|
+
collection: this.name,
|
|
70
|
+
operation: "read",
|
|
71
|
+
};
|
|
72
|
+
// Use cached/indexed data if available
|
|
73
|
+
if (this.dataLoaded) {
|
|
74
|
+
// If it's an 'eq' filter we can potentially use index directly
|
|
75
|
+
if (typeof queryOrPredicate !== "function" &&
|
|
76
|
+
queryOrPredicate?.filters &&
|
|
77
|
+
queryOrPredicate.filters.length === 1 &&
|
|
78
|
+
queryOrPredicate.filters[0].operator === "eq" &&
|
|
79
|
+
this.indexer.hasIndex(queryOrPredicate.filters[0].field)) {
|
|
80
|
+
const filter = queryOrPredicate.filters[0];
|
|
81
|
+
const results = this.indexer.query(filter.field, filter.value);
|
|
82
|
+
if (results !== null) {
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Fallback to full scan of current in-memory items
|
|
87
|
+
if (typeof queryOrPredicate === "function") {
|
|
88
|
+
return this.items.filter(queryOrPredicate);
|
|
89
|
+
}
|
|
90
|
+
if (queryOrPredicate) {
|
|
91
|
+
return this.applyQueryOptions(this.items, queryOrPredicate);
|
|
92
|
+
}
|
|
93
|
+
return this.items;
|
|
94
|
+
}
|
|
95
|
+
const response = await this.storage.readJson(this.path);
|
|
96
|
+
this.lastSha = response.sha;
|
|
97
|
+
items = response.data;
|
|
98
|
+
items = await Promise.all(items.map(async (item) => {
|
|
99
|
+
let currentItem = item;
|
|
100
|
+
for (const mw of this.middleware) {
|
|
101
|
+
if (mw.afterRead) {
|
|
102
|
+
currentItem = await mw.afterRead(currentItem, context);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return currentItem;
|
|
106
|
+
}));
|
|
107
|
+
// Build index and store in-memory items
|
|
108
|
+
if (!this.dataLoaded) {
|
|
109
|
+
this.items = items;
|
|
110
|
+
this.indexer.build(items, items.length > 0 ? Object.keys(items[0]) : []);
|
|
111
|
+
this.dataLoaded = true;
|
|
112
|
+
}
|
|
113
|
+
if (typeof queryOrPredicate === "function") {
|
|
114
|
+
return items.filter(queryOrPredicate);
|
|
115
|
+
}
|
|
116
|
+
if (queryOrPredicate) {
|
|
117
|
+
return this.applyQueryOptions(items, queryOrPredicate);
|
|
118
|
+
}
|
|
119
|
+
return items;
|
|
120
|
+
}
|
|
121
|
+
applyQueryOptions(items, options) {
|
|
122
|
+
let result = [...items];
|
|
123
|
+
// Apply filters
|
|
124
|
+
if (options.filters) {
|
|
125
|
+
for (const filter of options.filters) {
|
|
126
|
+
result = result.filter((item) => {
|
|
127
|
+
const val = item[filter.field];
|
|
128
|
+
switch (filter.operator) {
|
|
129
|
+
case "eq":
|
|
130
|
+
return val === filter.value;
|
|
131
|
+
case "neq":
|
|
132
|
+
return val !== filter.value;
|
|
133
|
+
case "gt":
|
|
134
|
+
return val > filter.value;
|
|
135
|
+
case "gte":
|
|
136
|
+
return val >= filter.value;
|
|
137
|
+
case "lt":
|
|
138
|
+
return val < filter.value;
|
|
139
|
+
case "lte":
|
|
140
|
+
return val <= filter.value;
|
|
141
|
+
case "contains":
|
|
142
|
+
return (typeof val === "string" && val.includes(filter.value));
|
|
143
|
+
case "in":
|
|
144
|
+
return Array.isArray(filter.value) && filter.value.includes(val);
|
|
145
|
+
default:
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Apply sorting
|
|
152
|
+
if (options.sort) {
|
|
153
|
+
for (const sort of options.sort) {
|
|
154
|
+
result.sort((a, b) => {
|
|
155
|
+
const aVal = a[sort.field];
|
|
156
|
+
const bVal = b[sort.field];
|
|
157
|
+
if (aVal < bVal)
|
|
158
|
+
return sort.order === "asc" ? -1 : 1;
|
|
159
|
+
if (aVal > bVal)
|
|
160
|
+
return sort.order === "asc" ? 1 : -1;
|
|
161
|
+
return 0;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Apply pagination
|
|
166
|
+
if (options.pagination) {
|
|
167
|
+
const { limit, offset = 0 } = options.pagination;
|
|
168
|
+
result = result.slice(offset, limit ? offset + limit : undefined);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
async findById(id) {
|
|
173
|
+
if (this.dataLoaded && this.indexer.hasIndex("id")) {
|
|
174
|
+
const results = this.indexer.query("id", id);
|
|
175
|
+
return results && results.length > 0 ? results[0] : null;
|
|
176
|
+
}
|
|
177
|
+
const items = await this.find();
|
|
178
|
+
return items.find((item) => item.id === id) || null;
|
|
179
|
+
}
|
|
180
|
+
async update(id, updates) {
|
|
181
|
+
const items = await this.find();
|
|
182
|
+
const index = items.findIndex((item) => item.id === id);
|
|
183
|
+
if (index === -1) {
|
|
184
|
+
throw new Error(`Item with id ${id} not found in ${this.name}`);
|
|
185
|
+
}
|
|
186
|
+
const originalItem = items[index];
|
|
187
|
+
items[index] = { ...items[index], ...updates };
|
|
188
|
+
let finalItem = items[index];
|
|
189
|
+
const context = {
|
|
190
|
+
collection: this.name,
|
|
191
|
+
operation: "update",
|
|
192
|
+
};
|
|
193
|
+
for (const mw of this.middleware) {
|
|
194
|
+
if (mw.beforeSave) {
|
|
195
|
+
finalItem = await mw.beforeSave(finalItem, context);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
items[index] = finalItem;
|
|
199
|
+
try {
|
|
200
|
+
this.lastSha = await this.storage.writeJson(this.path, items, `Update item ${id} in ${this.name}`, this.lastSha);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof ConcurrencyError) {
|
|
204
|
+
this.dataLoaded = false;
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
if (this.dataLoaded) {
|
|
209
|
+
this.items = items;
|
|
210
|
+
this.indexer.update(originalItem, finalItem);
|
|
211
|
+
}
|
|
212
|
+
return items[index];
|
|
213
|
+
}
|
|
214
|
+
async delete(id) {
|
|
215
|
+
const items = await this.find();
|
|
216
|
+
const index = items.findIndex((item) => item.id === id);
|
|
217
|
+
if (index === -1) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const itemToDelete = items[index];
|
|
221
|
+
const filtered = items.filter((_, i) => i !== index);
|
|
222
|
+
try {
|
|
223
|
+
this.lastSha = await this.storage.writeJson(this.path, filtered, `Delete item ${id} from ${this.name}`, this.lastSha);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof ConcurrencyError) {
|
|
227
|
+
this.dataLoaded = false;
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
if (this.dataLoaded) {
|
|
232
|
+
this.items = filtered;
|
|
233
|
+
this.indexer.remove(itemToDelete);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GitHubDBConfig, IStorageProvider, Middleware, Schema } from "../core/types.js";
|
|
2
|
+
import { Collection } from "./collection.js";
|
|
3
|
+
export declare class GitHubDB {
|
|
4
|
+
readonly config: GitHubDBConfig;
|
|
5
|
+
readonly storage: IStorageProvider;
|
|
6
|
+
constructor(config: GitHubDBConfig);
|
|
7
|
+
connect(): Promise<boolean>;
|
|
8
|
+
collection<T extends Schema>(name: string, options?: {
|
|
9
|
+
middleware?: Middleware<T>[];
|
|
10
|
+
}): Collection<T>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { GitHubStorageProvider } from "../infrastructure/github-storage.js";
|
|
2
|
+
import { Collection } from "./collection.js";
|
|
3
|
+
export class GitHubDB {
|
|
4
|
+
config;
|
|
5
|
+
storage;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
if (!config.accessToken) {
|
|
9
|
+
throw new Error("accessToken is required");
|
|
10
|
+
}
|
|
11
|
+
if (!config.owner) {
|
|
12
|
+
throw new Error("owner is required");
|
|
13
|
+
}
|
|
14
|
+
if (!config.repo) {
|
|
15
|
+
throw new Error("repo is required");
|
|
16
|
+
}
|
|
17
|
+
this.storage = new GitHubStorageProvider(config);
|
|
18
|
+
}
|
|
19
|
+
async connect() {
|
|
20
|
+
return this.storage.testConnection();
|
|
21
|
+
}
|
|
22
|
+
collection(name, options = {}) {
|
|
23
|
+
return new Collection(name, this.storage, options.middleware);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gh-as-db",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Use a private GitHub repository as a database for your application.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"gh-as-db": "./dist/ui/cli/index.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest",
|
|
24
|
+
"test:run": "vitest run",
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"github",
|
|
30
|
+
"database",
|
|
31
|
+
"storage",
|
|
32
|
+
"orm",
|
|
33
|
+
"json",
|
|
34
|
+
"collection"
|
|
35
|
+
],
|
|
36
|
+
"author": "Tommy Hansen",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/almyk/gh-as-db.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/almyk/gh-as-db/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/almyk/gh-as-db#readme",
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^25.0.8",
|
|
51
|
+
"@vitest/coverage-v8": "^4.0.17",
|
|
52
|
+
"ts-node": "^10.9.2",
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"vitest": "^4.0.17"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@octokit/rest": "^22.0.1",
|
|
58
|
+
"boxen": "^8.0.1",
|
|
59
|
+
"chalk": "^5.4.1",
|
|
60
|
+
"commander": "^13.1.0",
|
|
61
|
+
"enquirer": "^2.4.1"
|
|
62
|
+
}
|
|
63
|
+
}
|