@series-inc/stowkit-cli 0.6.34 → 0.6.36

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,13 @@
1
+ import { AssetType } from './core/types.js';
2
+ /**
3
+ * Verify that a Gemini API key is valid by making a minimal request.
4
+ */
5
+ export declare function verifyApiKey(apiKey: string): Promise<{
6
+ valid: boolean;
7
+ error?: string;
8
+ }>;
9
+ /**
10
+ * Tag a single asset by sending its thumbnail/audio to Gemini.
11
+ * Returns an array of lowercase tag strings.
12
+ */
13
+ export declare function tagAsset(apiKey: string, assetType: AssetType, fileName: string, inputData: Buffer, mimeType: string): Promise<string[]>;
@@ -0,0 +1,95 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import { AssetType } from './core/types.js';
3
+ const MODEL = 'gemini-2.5-flash-lite-preview';
4
+ const ASSET_TYPE_LABELS = {
5
+ [AssetType.Texture2D]: 'texture',
6
+ [AssetType.StaticMesh]: '3D mesh',
7
+ [AssetType.SkinnedMesh]: 'skinned 3D mesh',
8
+ [AssetType.AnimationClip]: 'animation',
9
+ [AssetType.Audio]: 'audio clip',
10
+ [AssetType.GlbContainer]: '3D model',
11
+ };
12
+ function buildPrompt(assetType, fileName) {
13
+ const label = ASSET_TYPE_LABELS[assetType] ?? 'asset';
14
+ return `You are a strict asset tagging engine for a game development library.
15
+
16
+ This is a game ${label} (${fileName}).
17
+
18
+ Return only a JSON array of concise lowercase asset tags.
19
+
20
+ Rules:
21
+ - 4 to 12 tags
22
+ - lowercase only
23
+ - no duplicates
24
+ - short searchable tags only
25
+ - no explanations
26
+ - no generic tags like asset, file, media, content
27
+ - only include tags directly supported by the input`;
28
+ }
29
+ /**
30
+ * Verify that a Gemini API key is valid by making a minimal request.
31
+ */
32
+ export async function verifyApiKey(apiKey) {
33
+ try {
34
+ const client = new GoogleGenAI({ apiKey });
35
+ const response = await client.models.generateContent({
36
+ model: MODEL,
37
+ contents: [{ role: 'user', parts: [{ text: 'Respond with the word "ok"' }] }],
38
+ config: { maxOutputTokens: 10 },
39
+ });
40
+ const text = response.text?.trim().toLowerCase() ?? '';
41
+ return { valid: text.includes('ok') };
42
+ }
43
+ catch (err) {
44
+ return { valid: false, error: err.message };
45
+ }
46
+ }
47
+ /**
48
+ * Tag a single asset by sending its thumbnail/audio to Gemini.
49
+ * Returns an array of lowercase tag strings.
50
+ */
51
+ export async function tagAsset(apiKey, assetType, fileName, inputData, mimeType) {
52
+ const client = new GoogleGenAI({ apiKey });
53
+ const response = await client.models.generateContent({
54
+ model: MODEL,
55
+ contents: [{
56
+ role: 'user',
57
+ parts: [
58
+ { text: buildPrompt(assetType, fileName) },
59
+ {
60
+ inlineData: {
61
+ data: inputData.toString('base64'),
62
+ mimeType,
63
+ },
64
+ },
65
+ ],
66
+ }],
67
+ config: {
68
+ temperature: 0.3,
69
+ topP: 0.95,
70
+ maxOutputTokens: 1024,
71
+ responseMimeType: 'application/json',
72
+ responseSchema: {
73
+ type: 'ARRAY',
74
+ items: { type: 'STRING' },
75
+ minItems: 4,
76
+ maxItems: 12,
77
+ },
78
+ safetySettings: [
79
+ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
80
+ { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
81
+ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
82
+ { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
83
+ ],
84
+ thinkingConfig: { thinkingBudget: 0 },
85
+ },
86
+ });
87
+ const text = response.text ?? '[]';
88
+ const parsed = JSON.parse(text);
89
+ if (!Array.isArray(parsed))
90
+ return [];
91
+ return parsed
92
+ .filter((t) => typeof t === 'string')
93
+ .map((t) => t.toLowerCase().trim())
94
+ .filter((t) => t.length > 0);
95
+ }
@@ -27,6 +27,9 @@ export interface FelicityProject {
27
27
  prefabsPath?: string;
28
28
  packs?: PackConfig[];
29
29
  defaults?: ProjectDefaults;
30
+ ai?: {
31
+ geminiApiKey?: string;
32
+ };
30
33
  }
31
34
  export interface FileSnapshot {
32
35
  relativePath: string;
@@ -34,25 +34,11 @@ export interface RegistryVersion {
34
34
  totalSize: number;
35
35
  assets: RegistryAsset[];
36
36
  }
37
- export interface RegistryPackage {
38
- description: string;
39
- author: string;
40
- tags: string[];
41
- latest: string;
42
- versions: Record<string, RegistryVersion>;
43
- /** Pack-level thumbnail filename (e.g. "thumbnail.webp"), uploaded during publish */
44
- thumbnail?: string;
45
- }
46
- export interface Registry {
47
- schemaVersion: number;
48
- packages: Record<string, RegistryPackage>;
49
- }
50
37
  export declare function readAssetsPackage(dir: string): Promise<AssetsPackage | null>;
51
38
  export declare function writeAssetsPackage(dir: string, pkg: AssetsPackage): Promise<void>;
52
39
  export declare function initAssetsPackage(dir: string, config: {
53
40
  name: string;
54
41
  }): Promise<AssetsPackage>;
55
- export declare function createEmptyRegistry(): Registry;
56
42
  /**
57
43
  * Given a set of requested stringIds and a version's asset list,
58
44
  * returns the full set of stringIds needed (transitive closure).
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  // ─── Constants ───────────────────────────────────────────────────────────────
4
4
  const ASSETS_PACKAGE_FILE = 'assets-package.json';
5
- const DEFAULT_BUCKET = 'venus-shared-assets-test';
5
+ const DEFAULT_BUCKET = 'rungame-shared-assets-test';
6
6
  // ─── Read / Write / Init ─────────────────────────────────────────────────────
7
7
  export async function readAssetsPackage(dir) {
8
8
  try {
@@ -30,9 +30,6 @@ export async function initAssetsPackage(dir, config) {
30
30
  await writeAssetsPackage(dir, pkg);
31
31
  return pkg;
32
32
  }
33
- export function createEmptyRegistry() {
34
- return { schemaVersion: 1, packages: {} };
35
- }
36
33
  // ─── Dependency Resolution ───────────────────────────────────────────────────
37
34
  /**
38
35
  * Given a set of requested stringIds and a version's asset list,
package/dist/cli.js CHANGED
@@ -11,7 +11,6 @@ import { createMaterial } from './create-material.js';
11
11
  import { renameAsset, moveAsset, deleteAsset, setStringId } from './asset-commands.js';
12
12
  import { inspectPack } from './inspect.js';
13
13
  import { publishPackage } from './publish.js';
14
- import { storeSearch, storeList, storeInfo } from './store.js';
15
14
  const args = process.argv.slice(2);
16
15
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
17
16
  const STOWKIT_PACKAGES = [
@@ -108,7 +107,6 @@ Options:
108
107
  --schema Material schema template: pbr (default), unlit, or custom name
109
108
  --bucket GCS bucket for publish/store (overrides default)
110
109
  --dry-run Show what would be published without uploading
111
- --json Output store results as JSON (for AI agents)
112
110
  --type Filter store search by asset type
113
111
  --limit Max number of results to return (default: all)
114
112
  --help Show this help message
@@ -163,7 +161,6 @@ async function main() {
163
161
  const portIdx = args.indexOf('--port');
164
162
  const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : 3210;
165
163
  const dryRun = args.includes('--dry-run');
166
- const jsonOutput = args.includes('--json');
167
164
  const bucketIdx = args.indexOf('--bucket');
168
165
  const bucket = bucketIdx >= 0 ? args[bucketIdx + 1] : undefined;
169
166
  const typeIdx = args.indexOf('--type');
@@ -306,35 +303,29 @@ async function main() {
306
303
  const subCmd = args[1];
307
304
  const serverUrl = `http://localhost:${port}`;
308
305
  const serverUp = await isStowKitRunning(port);
306
+ if (!serverUp) {
307
+ console.error('StowKit server is not running. Start it with: stowkit serve');
308
+ process.exit(1);
309
+ }
309
310
  if (subCmd === 'search') {
310
311
  const query = args.filter(a => !a.startsWith('-') && a !== 'store' && a !== 'search').join(' ');
311
312
  if (!query) {
312
- console.error('Usage: stowkit store search <query> [--type <type>] [--limit <n>] [--json]');
313
+ console.error('Usage: stowkit store search <query> [--type <type>] [--limit <n>]');
313
314
  process.exit(1);
314
315
  }
315
- if (serverUp) {
316
- const params = new URLSearchParams({ q: query });
317
- if (typeFilter)
318
- params.set('type', typeFilter);
319
- if (limitFilter)
320
- params.set('limit', String(limitFilter));
321
- const res = await fetch(`${serverUrl}/api/asset-store/search?${params}`);
322
- const results = await res.json();
323
- console.log(JSON.stringify(results, null, 2));
324
- }
325
- else {
326
- await storeSearch(query, { json: jsonOutput, bucket, type: typeFilter, limit: limitFilter });
327
- }
316
+ const params = new URLSearchParams({ q: query });
317
+ if (typeFilter)
318
+ params.set('type', typeFilter);
319
+ if (limitFilter)
320
+ params.set('limit', String(limitFilter));
321
+ const res = await fetch(`${serverUrl}/api/asset-store/search?${params}`);
322
+ const results = await res.json();
323
+ console.log(JSON.stringify(results, null, 2));
328
324
  }
329
325
  else if (subCmd === 'list') {
330
- if (serverUp) {
331
- const res = await fetch(`${serverUrl}/api/asset-store/packages`);
332
- const packages = await res.json();
333
- console.log(JSON.stringify(packages, null, 2));
334
- }
335
- else {
336
- await storeList({ json: jsonOutput, bucket });
337
- }
326
+ const res = await fetch(`${serverUrl}/api/asset-store/packages`);
327
+ const packages = await res.json();
328
+ console.log(JSON.stringify(packages, null, 2));
338
329
  }
339
330
  else if (subCmd === 'info') {
340
331
  const pkgName = args.find(a => !a.startsWith('-') && a !== 'store' && a !== 'info');
@@ -342,14 +333,9 @@ async function main() {
342
333
  console.error('Usage: stowkit store info <package-name> [--json]');
343
334
  process.exit(1);
344
335
  }
345
- if (serverUp) {
346
- const res = await fetch(`${serverUrl}/api/asset-store/package/${encodeURIComponent(pkgName)}`);
347
- const info = await res.json();
348
- console.log(JSON.stringify(info, null, 2));
349
- }
350
- else {
351
- await storeInfo(pkgName, { json: jsonOutput, bucket });
352
- }
336
+ const res = await fetch(`${serverUrl}/api/asset-store/package/${encodeURIComponent(pkgName)}`);
337
+ const info = await res.json();
338
+ console.log(JSON.stringify(info, null, 2));
353
339
  }
354
340
  else {
355
341
  console.error('Usage: stowkit store <search|list|info> [args]');
@@ -0,0 +1,42 @@
1
+ import type { RegistryAsset } from './assets-package.js';
2
+ export interface FirestorePackageDoc {
3
+ description: string;
4
+ author: string;
5
+ tags: string[];
6
+ latest: string;
7
+ thumbnail: string | null;
8
+ }
9
+ export interface FirestoreVersionDoc {
10
+ publishedAt: string;
11
+ fileCount: number;
12
+ totalSize: number;
13
+ assets: RegistryAsset[];
14
+ }
15
+ /** Read-only client — uses public API key, no credentials needed */
16
+ export interface FirestoreReader {
17
+ projectId: string;
18
+ getPackage(name: string): Promise<FirestorePackageDoc | null>;
19
+ listPackages(): Promise<Array<{
20
+ name: string;
21
+ data: FirestorePackageDoc;
22
+ }>>;
23
+ getVersion(packageName: string, version: string): Promise<FirestoreVersionDoc | null>;
24
+ listVersionKeys(packageName: string): Promise<string[]>;
25
+ }
26
+ /** Read-write client — uses service account, needed for publishing */
27
+ export interface FirestoreClient extends FirestoreReader {
28
+ setPackage(name: string, data: FirestorePackageDoc): Promise<void>;
29
+ deletePackage(name: string): Promise<void>;
30
+ setVersion(packageName: string, version: string, data: FirestoreVersionDoc): Promise<void>;
31
+ }
32
+ /**
33
+ * Create a read-only Firestore client using the public API key.
34
+ * No service account or credentials needed — safe for CLI store commands,
35
+ * packer GUI browsing, and server read endpoints.
36
+ */
37
+ export declare function createFirestoreReader(): FirestoreReader;
38
+ /**
39
+ * Create a read-write Firestore client using a service account.
40
+ * Required for publishing — needs service_account.json in the project directory.
41
+ */
42
+ export declare function createFirestoreClient(projectDir: string): Promise<FirestoreClient>;
@@ -0,0 +1,273 @@
1
+ import { loadServiceAccount, getAccessToken, FIRESTORE_SCOPE, GCS_SCOPE } from './gcp-auth.js';
2
+ // ─── Constants ───────────────────────────────────────────────────────────────
3
+ const FIRESTORE_PROJECT_ID = 'run-shared-assets';
4
+ const FIRESTORE_API_KEY = 'AIzaSyCgat6uQgWI2_w2_5rK90RyuGbLs-Shp8Q';
5
+ function toFirestore(value) {
6
+ if (value === null || value === undefined)
7
+ return { nullValue: null };
8
+ if (typeof value === 'string')
9
+ return { stringValue: value };
10
+ if (typeof value === 'boolean')
11
+ return { booleanValue: value };
12
+ if (typeof value === 'number') {
13
+ if (Number.isInteger(value))
14
+ return { integerValue: String(value) };
15
+ return { doubleValue: value };
16
+ }
17
+ if (Array.isArray(value)) {
18
+ if (value.length === 0)
19
+ return { arrayValue: {} };
20
+ return { arrayValue: { values: value.map(toFirestore) } };
21
+ }
22
+ if (typeof value === 'object') {
23
+ const fields = {};
24
+ for (const [k, v] of Object.entries(value)) {
25
+ fields[k] = toFirestore(v);
26
+ }
27
+ return { mapValue: { fields } };
28
+ }
29
+ return { stringValue: String(value) };
30
+ }
31
+ function fromFirestore(value) {
32
+ if ('stringValue' in value)
33
+ return value.stringValue;
34
+ if ('integerValue' in value)
35
+ return Number(value.integerValue);
36
+ if ('doubleValue' in value)
37
+ return value.doubleValue;
38
+ if ('booleanValue' in value)
39
+ return value.booleanValue;
40
+ if ('nullValue' in value)
41
+ return null;
42
+ if ('arrayValue' in value) {
43
+ return (value.arrayValue.values ?? []).map(fromFirestore);
44
+ }
45
+ if ('mapValue' in value) {
46
+ const fields = value.mapValue.fields;
47
+ if (!fields)
48
+ return {};
49
+ const result = {};
50
+ for (const [k, v] of Object.entries(fields)) {
51
+ result[k] = fromFirestore(v);
52
+ }
53
+ return result;
54
+ }
55
+ return null;
56
+ }
57
+ function docToObject(fields) {
58
+ if (!fields)
59
+ return null;
60
+ const result = {};
61
+ for (const [k, v] of Object.entries(fields)) {
62
+ result[k] = fromFirestore(v);
63
+ }
64
+ return result;
65
+ }
66
+ function objectToFields(data) {
67
+ const fields = {};
68
+ for (const [k, v] of Object.entries(data)) {
69
+ fields[k] = toFirestore(v);
70
+ }
71
+ return fields;
72
+ }
73
+ // ─── In-memory cache ─────────────────────────────────────────────────────────
74
+ const CACHE_TTL_MS = 60_000; // 1 minute
75
+ const cache = new Map();
76
+ function cacheGet(key) {
77
+ const entry = cache.get(key);
78
+ if (!entry)
79
+ return undefined;
80
+ if (Date.now() > entry.expiry) {
81
+ cache.delete(key);
82
+ return undefined;
83
+ }
84
+ return entry.value;
85
+ }
86
+ function cacheSet(key, value) {
87
+ cache.set(key, { value, expiry: Date.now() + CACHE_TTL_MS });
88
+ return value;
89
+ }
90
+ // ─── REST helpers ────────────────────────────────────────────────────────────
91
+ const BASE = 'https://firestore.googleapis.com/v1';
92
+ function docPath(projectId, ...segments) {
93
+ return `${BASE}/projects/${projectId}/databases/(default)/documents/${segments.join('/')}`;
94
+ }
95
+ // ─── Shared read operations ─────────────────────────────────────────────────
96
+ function buildReadOps(projectId, fetchWithAuth) {
97
+ async function getPackage(name) {
98
+ const key = `pkg:${projectId}:${name}`;
99
+ const cached = cacheGet(key);
100
+ if (cached !== undefined)
101
+ return cached;
102
+ const res = await fetchWithAuth(docPath(projectId, 'packages', name));
103
+ if (res.status === 404)
104
+ return cacheSet(key, null);
105
+ if (!res.ok) {
106
+ const text = await res.text();
107
+ throw new Error(`Firestore GET packages/${name} failed (${res.status}): ${text}`);
108
+ }
109
+ const doc = (await res.json());
110
+ return cacheSet(key, docToObject(doc.fields));
111
+ }
112
+ async function listPackages() {
113
+ const key = `pkgList:${projectId}`;
114
+ const cached = cacheGet(key);
115
+ if (cached)
116
+ return cached;
117
+ const results = [];
118
+ let pageToken;
119
+ do {
120
+ const params = new URLSearchParams({ pageSize: '300' });
121
+ if (pageToken)
122
+ params.set('pageToken', pageToken);
123
+ const res = await fetchWithAuth(`${docPath(projectId, 'packages')}?${params}`);
124
+ if (!res.ok) {
125
+ const text = await res.text();
126
+ throw new Error(`Firestore LIST packages failed (${res.status}): ${text}`);
127
+ }
128
+ const body = (await res.json());
129
+ if (body.documents) {
130
+ for (const doc of body.documents) {
131
+ const docId = doc.name.split('/').pop();
132
+ const data = docToObject(doc.fields);
133
+ if (data)
134
+ results.push({ name: docId, data });
135
+ }
136
+ }
137
+ pageToken = body.nextPageToken;
138
+ } while (pageToken);
139
+ return cacheSet(key, results);
140
+ }
141
+ async function getVersion(packageName, version) {
142
+ const key = `ver:${projectId}:${packageName}:${version}`;
143
+ const cached = cacheGet(key);
144
+ if (cached !== undefined)
145
+ return cached;
146
+ const res = await fetchWithAuth(docPath(projectId, 'packages', packageName, 'versions', version));
147
+ if (res.status === 404)
148
+ return cacheSet(key, null);
149
+ if (!res.ok) {
150
+ const text = await res.text();
151
+ throw new Error(`Firestore GET packages/${packageName}/versions/${version} failed (${res.status}): ${text}`);
152
+ }
153
+ const doc = (await res.json());
154
+ return cacheSet(key, docToObject(doc.fields));
155
+ }
156
+ async function listVersionKeys(packageName) {
157
+ const key = `verKeys:${projectId}:${packageName}`;
158
+ const cached = cacheGet(key);
159
+ if (cached)
160
+ return cached;
161
+ const results = [];
162
+ let pageToken;
163
+ do {
164
+ const params = new URLSearchParams({ pageSize: '300' });
165
+ if (pageToken)
166
+ params.set('pageToken', pageToken);
167
+ const res = await fetchWithAuth(`${docPath(projectId, 'packages', packageName, 'versions')}?${params}`);
168
+ if (!res.ok) {
169
+ const text = await res.text();
170
+ throw new Error(`Firestore LIST versions for ${packageName} failed (${res.status}): ${text}`);
171
+ }
172
+ const body = (await res.json());
173
+ if (body.documents) {
174
+ for (const doc of body.documents) {
175
+ results.push(doc.name.split('/').pop());
176
+ }
177
+ }
178
+ pageToken = body.nextPageToken;
179
+ } while (pageToken);
180
+ return cacheSet(key, results);
181
+ }
182
+ return { getPackage, listPackages, getVersion, listVersionKeys };
183
+ }
184
+ // ─── Public Reader (API key, no credentials) ────────────────────────────────
185
+ /**
186
+ * Create a read-only Firestore client using the public API key.
187
+ * No service account or credentials needed — safe for CLI store commands,
188
+ * packer GUI browsing, and server read endpoints.
189
+ */
190
+ export function createFirestoreReader() {
191
+ const projectId = FIRESTORE_PROJECT_ID;
192
+ const apiKey = FIRESTORE_API_KEY;
193
+ function fetchWithAuth(url) {
194
+ const sep = url.includes('?') ? '&' : '?';
195
+ return fetch(`${url}${sep}key=${apiKey}`);
196
+ }
197
+ return { projectId, ...buildReadOps(projectId, fetchWithAuth) };
198
+ }
199
+ // ─── Authenticated Writer (service account) ──────────────────────────────────
200
+ /**
201
+ * Create a read-write Firestore client using a service account.
202
+ * Required for publishing — needs service_account.json in the project directory.
203
+ */
204
+ export async function createFirestoreClient(projectDir) {
205
+ const sa = await loadServiceAccount(projectDir);
206
+ const projectId = sa.project_id;
207
+ let token = null;
208
+ let tokenExpiry = 0;
209
+ async function getToken() {
210
+ const now = Date.now();
211
+ if (token && now < tokenExpiry)
212
+ return token;
213
+ token = await getAccessToken(sa, [FIRESTORE_SCOPE, GCS_SCOPE]);
214
+ tokenExpiry = now + 55 * 60 * 1000;
215
+ return token;
216
+ }
217
+ function fetchWithAuth(url) {
218
+ return getToken().then(t => fetch(url, {
219
+ headers: { Authorization: `Bearer ${t}` },
220
+ }));
221
+ }
222
+ const readOps = buildReadOps(projectId, fetchWithAuth);
223
+ async function setPackage(name, data) {
224
+ const t = await getToken();
225
+ const url = docPath(projectId, 'packages', name);
226
+ const body = JSON.stringify({ fields: objectToFields(data) });
227
+ const res = await fetch(url, {
228
+ method: 'PATCH',
229
+ headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
230
+ body,
231
+ });
232
+ if (!res.ok) {
233
+ const text = await res.text();
234
+ throw new Error(`Firestore PATCH packages/${name} failed (${res.status}): ${text}`);
235
+ }
236
+ }
237
+ async function deletePackage(name) {
238
+ const t = await getToken();
239
+ // Delete version subcollection docs first
240
+ const versions = await readOps.listVersionKeys(name);
241
+ for (const ver of versions) {
242
+ const url = docPath(projectId, 'packages', name, 'versions', ver);
243
+ await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } });
244
+ }
245
+ const url = docPath(projectId, 'packages', name);
246
+ const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } });
247
+ if (!res.ok && res.status !== 404) {
248
+ const text = await res.text();
249
+ throw new Error(`Firestore DELETE packages/${name} failed (${res.status}): ${text}`);
250
+ }
251
+ }
252
+ async function setVersion(packageName, version, data) {
253
+ const t = await getToken();
254
+ const url = docPath(projectId, 'packages', packageName, 'versions', version);
255
+ const body = JSON.stringify({ fields: objectToFields(data) });
256
+ const res = await fetch(url, {
257
+ method: 'PATCH',
258
+ headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
259
+ body,
260
+ });
261
+ if (!res.ok) {
262
+ const text = await res.text();
263
+ throw new Error(`Firestore PATCH packages/${packageName}/versions/${version} failed (${res.status}): ${text}`);
264
+ }
265
+ }
266
+ return {
267
+ projectId,
268
+ ...readOps,
269
+ setPackage,
270
+ deletePackage,
271
+ setVersion,
272
+ };
273
+ }
@@ -0,0 +1,9 @@
1
+ export interface ServiceAccount {
2
+ client_email: string;
3
+ private_key: string;
4
+ project_id: string;
5
+ }
6
+ export declare const GCS_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write";
7
+ export declare const FIRESTORE_SCOPE = "https://www.googleapis.com/auth/datastore";
8
+ export declare function getAccessToken(sa: ServiceAccount, scopes: string[]): Promise<string>;
9
+ export declare function loadServiceAccount(projectDir: string): Promise<ServiceAccount>;
@@ -0,0 +1,69 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as crypto from 'node:crypto';
4
+ // ─── Scopes ──────────────────────────────────────────────────────────────────
5
+ export const GCS_SCOPE = 'https://www.googleapis.com/auth/devstorage.read_write';
6
+ export const FIRESTORE_SCOPE = 'https://www.googleapis.com/auth/datastore';
7
+ // ─── JWT Auth ────────────────────────────────────────────────────────────────
8
+ function base64url(data) {
9
+ const buf = typeof data === 'string' ? Buffer.from(data) : data;
10
+ return buf.toString('base64url');
11
+ }
12
+ function createJWT(sa, scopes) {
13
+ const now = Math.floor(Date.now() / 1000);
14
+ const header = { alg: 'RS256', typ: 'JWT' };
15
+ const payload = {
16
+ iss: sa.client_email,
17
+ scope: scopes.join(' '),
18
+ aud: 'https://oauth2.googleapis.com/token',
19
+ iat: now,
20
+ exp: now + 3600,
21
+ };
22
+ const segments = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
23
+ const sign = crypto.createSign('RSA-SHA256');
24
+ sign.update(segments);
25
+ const signature = sign.sign(sa.private_key);
26
+ return `${segments}.${base64url(signature)}`;
27
+ }
28
+ export async function getAccessToken(sa, scopes) {
29
+ const jwt = createJWT(sa, scopes);
30
+ const res = await fetch('https://oauth2.googleapis.com/token', {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
33
+ body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`,
34
+ });
35
+ if (!res.ok) {
36
+ const text = await res.text();
37
+ throw new Error(`GCP auth failed (${res.status}): ${text}`);
38
+ }
39
+ const data = await res.json();
40
+ return data.access_token;
41
+ }
42
+ // ─── Credential Resolution ───────────────────────────────────────────────────
43
+ export async function loadServiceAccount(projectDir) {
44
+ // Search order: project dir, cwd, GOOGLE_APPLICATION_CREDENTIALS env
45
+ const candidates = [
46
+ path.join(projectDir, 'service_account.json'),
47
+ path.join(process.cwd(), 'service_account.json'),
48
+ ];
49
+ for (const candidate of candidates) {
50
+ try {
51
+ const text = await fs.readFile(candidate, 'utf-8');
52
+ return JSON.parse(text);
53
+ }
54
+ catch { /* not found */ }
55
+ }
56
+ // Fall back to GOOGLE_APPLICATION_CREDENTIALS
57
+ const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
58
+ if (envPath) {
59
+ try {
60
+ const text = await fs.readFile(envPath, 'utf-8');
61
+ return JSON.parse(text);
62
+ }
63
+ catch {
64
+ throw new Error(`Could not read service account from GOOGLE_APPLICATION_CREDENTIALS: ${envPath}`);
65
+ }
66
+ }
67
+ throw new Error('No GCS credentials found. Place service_account.json in project root, ' +
68
+ 'current directory, or set GOOGLE_APPLICATION_CREDENTIALS environment variable.');
69
+ }
package/dist/gcs.d.ts CHANGED
@@ -1,10 +1,5 @@
1
1
  export interface GCSClient {
2
2
  upload(objectPath: string, data: Uint8Array | string, contentType?: string): Promise<void>;
3
3
  download(objectPath: string): Promise<string | null>;
4
- downloadWithGeneration(objectPath: string): Promise<{
5
- data: string;
6
- generation: string;
7
- } | null>;
8
- uploadWithGeneration(objectPath: string, data: string, generation: string | null, contentType?: string): Promise<void>;
9
4
  }
10
5
  export declare function createGCSClient(projectDir: string, bucketUri: string): Promise<GCSClient>;