firebase-dataconnect-bootstrap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/bin/firebase-dataconnect-bootstrap.js +5 -0
- package/dist/cli.js +143 -0
- package/dist/main.js +370 -0
- package/dist/prompt.js +78 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,66 @@
|
|
|
1
|
+
# firebase-dataconnect-bootstrap
|
|
2
|
+
|
|
3
|
+
Firebase Data Connect の基本構成作成と、Firestore の `onDocumentWritten` Cloud Functions 追加を自動化する CLI です。
|
|
4
|
+
|
|
5
|
+
## できること
|
|
6
|
+
|
|
7
|
+
- 対象レポジトリへ `.firebaserc` と `firebase.json` を生成/更新
|
|
8
|
+
- `dataconnect/` 配下に初期ファイル群を作成
|
|
9
|
+
- `functions/` が未初期化なら最小構成で初期化
|
|
10
|
+
- Firestore `onDocumentWritten` 関数を追加して export
|
|
11
|
+
- 必要に応じて `functions/` で `npm install` を実行
|
|
12
|
+
|
|
13
|
+
## 使い方
|
|
14
|
+
|
|
15
|
+
初回セットアップ:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
npm run build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
対話モード:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx firebase-dataconnect-bootstrap
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
非対話モード:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx firebase-dataconnect-bootstrap \
|
|
32
|
+
--yes \
|
|
33
|
+
--target . \
|
|
34
|
+
--project your-firebase-project-id \
|
|
35
|
+
--region asia-northeast1 \
|
|
36
|
+
--service your-service-id \
|
|
37
|
+
--location asia-northeast1 \
|
|
38
|
+
--document 'meetingSummaries/{summaryId}' \
|
|
39
|
+
--function onMeetingSummaryWritten \
|
|
40
|
+
--install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## オプション
|
|
44
|
+
|
|
45
|
+
- `--target <path>`: 対象リポジトリ
|
|
46
|
+
- `--project <id>`: Firebase project id
|
|
47
|
+
- `--region <region>`: Functions region
|
|
48
|
+
- `--service <serviceId>`: Data Connect service id
|
|
49
|
+
- `--location <location>`: Data Connect location
|
|
50
|
+
- `--document <path>`: Firestore document path
|
|
51
|
+
- `--function <name>`: 関数の export 名
|
|
52
|
+
- `--install`: `functions` で `npm install` を実行
|
|
53
|
+
- `--no-install`: `npm install` をスキップ
|
|
54
|
+
- `--yes`: 対話なしで実行(`--project` は必須)
|
|
55
|
+
|
|
56
|
+
## npm 公開
|
|
57
|
+
|
|
58
|
+
1. `npm login`
|
|
59
|
+
2. パッケージ名が未使用か確認
|
|
60
|
+
3. `npm run build`
|
|
61
|
+
4. `npm publish --access public`
|
|
62
|
+
|
|
63
|
+
## 注意点
|
|
64
|
+
|
|
65
|
+
- Data Connect/Firebase CLI の認証やプロジェクト作成そのものは行いません。
|
|
66
|
+
- 既存の `functions` 実装に追記するため、デプロイ前に差分確認を推奨します。
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { collectConfig } from "./prompt.js";
|
|
2
|
+
import { run } from "./main.js";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
const HELP_TEXT = `firebase-dataconnect-bootstrap
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
npx firebase-dataconnect-bootstrap [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--target <path> Target repository path (default: current directory)
|
|
11
|
+
--project <id> Firebase project id
|
|
12
|
+
--region <region> Functions region (default: us-central1)
|
|
13
|
+
--service <serviceId> Data Connect service id
|
|
14
|
+
--location <location> Data Connect location (default: same as region)
|
|
15
|
+
--document <path> Firestore document path (default: meetingSummaries/{summaryId})
|
|
16
|
+
--function <name> Function export name (default: onMeetingSummaryWritten)
|
|
17
|
+
--install Run npm install in functions directory
|
|
18
|
+
--no-install Skip npm install in functions directory
|
|
19
|
+
--yes Use defaults and skip prompts where possible
|
|
20
|
+
-h, --help Show help
|
|
21
|
+
`;
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = {
|
|
24
|
+
targetDir: undefined,
|
|
25
|
+
projectId: undefined,
|
|
26
|
+
region: undefined,
|
|
27
|
+
serviceId: undefined,
|
|
28
|
+
location: undefined,
|
|
29
|
+
documentPath: undefined,
|
|
30
|
+
functionName: undefined,
|
|
31
|
+
installDependencies: undefined,
|
|
32
|
+
yes: false,
|
|
33
|
+
help: false
|
|
34
|
+
};
|
|
35
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
36
|
+
const token = argv[i];
|
|
37
|
+
if (token === "-h" || token === "--help") {
|
|
38
|
+
args.help = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (token === "--yes") {
|
|
42
|
+
args.yes = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (token === "--install") {
|
|
46
|
+
args.installDependencies = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (token === "--no-install") {
|
|
50
|
+
args.installDependencies = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (token.startsWith("--target=")) {
|
|
54
|
+
args.targetDir = token.slice("--target=".length);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (token.startsWith("--project=")) {
|
|
58
|
+
args.projectId = token.slice("--project=".length);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (token.startsWith("--region=")) {
|
|
62
|
+
args.region = token.slice("--region=".length);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (token.startsWith("--service=")) {
|
|
66
|
+
args.serviceId = token.slice("--service=".length);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (token.startsWith("--location=")) {
|
|
70
|
+
args.location = token.slice("--location=".length);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (token.startsWith("--document=")) {
|
|
74
|
+
args.documentPath = token.slice("--document=".length);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.startsWith("--function=")) {
|
|
78
|
+
args.functionName = token.slice("--function=".length);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const nextValue = argv[i + 1];
|
|
82
|
+
const useNext = (field) => {
|
|
83
|
+
if (!nextValue || nextValue.startsWith("-")) {
|
|
84
|
+
throw new Error(`Missing value for ${token}`);
|
|
85
|
+
}
|
|
86
|
+
args[field] = nextValue;
|
|
87
|
+
i += 1;
|
|
88
|
+
};
|
|
89
|
+
switch (token) {
|
|
90
|
+
case "--target":
|
|
91
|
+
useNext("targetDir");
|
|
92
|
+
break;
|
|
93
|
+
case "--project":
|
|
94
|
+
useNext("projectId");
|
|
95
|
+
break;
|
|
96
|
+
case "--region":
|
|
97
|
+
useNext("region");
|
|
98
|
+
break;
|
|
99
|
+
case "--service":
|
|
100
|
+
useNext("serviceId");
|
|
101
|
+
break;
|
|
102
|
+
case "--location":
|
|
103
|
+
useNext("location");
|
|
104
|
+
break;
|
|
105
|
+
case "--document":
|
|
106
|
+
useNext("documentPath");
|
|
107
|
+
break;
|
|
108
|
+
case "--function":
|
|
109
|
+
useNext("functionName");
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
throw new Error(`Unknown option: ${token}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return args;
|
|
116
|
+
}
|
|
117
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = parseArgs(argv);
|
|
120
|
+
if (parsed.help) {
|
|
121
|
+
console.log(HELP_TEXT);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const config = await collectConfig(parsed);
|
|
125
|
+
const result = await run(config);
|
|
126
|
+
console.log("");
|
|
127
|
+
console.log("Setup completed.");
|
|
128
|
+
console.log(`Target: ${result.targetDir}`);
|
|
129
|
+
for (const line of result.summaryLines) {
|
|
130
|
+
console.log(`- ${line}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error("");
|
|
135
|
+
console.error("Setup failed.");
|
|
136
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
141
|
+
if (import.meta.url === invokedPath) {
|
|
142
|
+
runCli();
|
|
143
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function toPosixPath(filePath) {
|
|
5
|
+
return filePath.split(path.sep).join("/");
|
|
6
|
+
}
|
|
7
|
+
async function exists(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
await access(filePath);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function readTextIfExists(filePath) {
|
|
17
|
+
if (!(await exists(filePath))) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return readFile(filePath, "utf8");
|
|
21
|
+
}
|
|
22
|
+
async function readJsonIfExists(filePath) {
|
|
23
|
+
const text = await readTextIfExists(filePath);
|
|
24
|
+
if (!text) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
}
|
|
29
|
+
async function writeJson(filePath, value) {
|
|
30
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
31
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
32
|
+
}
|
|
33
|
+
async function writeTextIfMissing(filePath, text) {
|
|
34
|
+
if (await exists(filePath)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
38
|
+
await writeFile(filePath, text, "utf8");
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function appendIfMissing(content, appendLine) {
|
|
42
|
+
if (content.includes(appendLine)) {
|
|
43
|
+
return { changed: false, content };
|
|
44
|
+
}
|
|
45
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
46
|
+
return {
|
|
47
|
+
changed: true,
|
|
48
|
+
content: `${content}${suffix}${appendLine}\n`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function detectModuleStyle(entryPath, packageJson) {
|
|
52
|
+
const ext = path.extname(entryPath);
|
|
53
|
+
if (ext === ".ts") {
|
|
54
|
+
return "esm";
|
|
55
|
+
}
|
|
56
|
+
if (ext === ".mjs") {
|
|
57
|
+
return "esm";
|
|
58
|
+
}
|
|
59
|
+
if (ext === ".cjs") {
|
|
60
|
+
return "cjs";
|
|
61
|
+
}
|
|
62
|
+
if (packageJson?.type === "module") {
|
|
63
|
+
return "esm";
|
|
64
|
+
}
|
|
65
|
+
return "cjs";
|
|
66
|
+
}
|
|
67
|
+
function buildDataconnectYaml(config) {
|
|
68
|
+
return `specVersion: "v1alpha"
|
|
69
|
+
serviceId: "${config.serviceId}"
|
|
70
|
+
location: "${config.location}"
|
|
71
|
+
schema: "./schema"
|
|
72
|
+
connectorDirs:
|
|
73
|
+
- "./example"
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
function buildSchemaGql() {
|
|
77
|
+
return `# Data Connect schema scaffold
|
|
78
|
+
type MeetingSummary @table(key: "id") {
|
|
79
|
+
id: String!
|
|
80
|
+
title: String!
|
|
81
|
+
summary: String!
|
|
82
|
+
transcript: String
|
|
83
|
+
createdAt: Timestamp!
|
|
84
|
+
recordedSeconds: Float
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
function buildQueriesGql() {
|
|
89
|
+
return `query SearchMeetingSummaries($searchQuery: String!) {
|
|
90
|
+
meetingSummaries(where: { title: { like: $searchQuery } }, orderBy: { createdAt: DESC }, first: 20) {
|
|
91
|
+
id
|
|
92
|
+
title
|
|
93
|
+
summary
|
|
94
|
+
createdAt
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
function buildMutationsGql() {
|
|
100
|
+
return `mutation UpsertMeetingSummary(
|
|
101
|
+
$id: String!
|
|
102
|
+
$title: String!
|
|
103
|
+
$summary: String!
|
|
104
|
+
$transcript: String
|
|
105
|
+
$createdAt: Timestamp!
|
|
106
|
+
$recordedSeconds: Float
|
|
107
|
+
) {
|
|
108
|
+
insertMeetingSummary(
|
|
109
|
+
data: {
|
|
110
|
+
id: $id
|
|
111
|
+
title: $title
|
|
112
|
+
summary: $summary
|
|
113
|
+
transcript: $transcript
|
|
114
|
+
createdAt: $createdAt
|
|
115
|
+
recordedSeconds: $recordedSeconds
|
|
116
|
+
}
|
|
117
|
+
onConflict: {
|
|
118
|
+
constraint: "meetingSummary_pkey"
|
|
119
|
+
updateColumns: [title, summary, transcript, createdAt, recordedSeconds]
|
|
120
|
+
}
|
|
121
|
+
) {
|
|
122
|
+
id
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
function buildFunctionModule({ style, functionName, documentPath, region }) {
|
|
128
|
+
if (style === "cjs") {
|
|
129
|
+
return `const { onDocumentWritten } = require("firebase-functions/v2/firestore");
|
|
130
|
+
const logger = require("firebase-functions/logger");
|
|
131
|
+
|
|
132
|
+
const ${functionName} = onDocumentWritten(
|
|
133
|
+
{ document: "${documentPath}", region: "${region}" },
|
|
134
|
+
async (event) => {
|
|
135
|
+
logger.info("Firestore onDocumentWritten fired", {
|
|
136
|
+
params: event.params,
|
|
137
|
+
hasBefore: Boolean(event.data?.before?.exists),
|
|
138
|
+
hasAfter: Boolean(event.data?.after?.exists)
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
module.exports = { ${functionName} };
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
return `import { onDocumentWritten } from "firebase-functions/v2/firestore";
|
|
149
|
+
import * as logger from "firebase-functions/logger";
|
|
150
|
+
|
|
151
|
+
export const ${functionName} = onDocumentWritten(
|
|
152
|
+
{ document: "${documentPath}", region: "${region}" },
|
|
153
|
+
async (event) => {
|
|
154
|
+
logger.info("Firestore onDocumentWritten fired", {
|
|
155
|
+
params: event.params,
|
|
156
|
+
hasBefore: Boolean(event.data?.before?.exists),
|
|
157
|
+
hasAfter: Boolean(event.data?.after?.exists)
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
async function ensureFirebaseRc(targetDir, projectId) {
|
|
166
|
+
const filePath = path.join(targetDir, ".firebaserc");
|
|
167
|
+
const current = (await readJsonIfExists(filePath)) ?? {};
|
|
168
|
+
const projects = typeof current.projects === "object" && current.projects !== null
|
|
169
|
+
? current.projects
|
|
170
|
+
: {};
|
|
171
|
+
const next = {
|
|
172
|
+
...current,
|
|
173
|
+
projects: {
|
|
174
|
+
...projects,
|
|
175
|
+
default: projectId
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
await writeJson(filePath, next);
|
|
179
|
+
return ".firebaserc";
|
|
180
|
+
}
|
|
181
|
+
async function ensureFirebaseJson(targetDir) {
|
|
182
|
+
const filePath = path.join(targetDir, "firebase.json");
|
|
183
|
+
const current = (await readJsonIfExists(filePath)) ?? {};
|
|
184
|
+
const next = { ...current };
|
|
185
|
+
if (!("functions" in next)) {
|
|
186
|
+
next.functions = { source: "functions" };
|
|
187
|
+
}
|
|
188
|
+
if (!("dataconnect" in next)) {
|
|
189
|
+
next.dataconnect = { source: "dataconnect" };
|
|
190
|
+
}
|
|
191
|
+
await writeJson(filePath, next);
|
|
192
|
+
return "firebase.json";
|
|
193
|
+
}
|
|
194
|
+
async function ensureDataConnectFiles(targetDir, config) {
|
|
195
|
+
const created = [];
|
|
196
|
+
const root = path.join(targetDir, "dataconnect");
|
|
197
|
+
if (await writeTextIfMissing(path.join(root, "dataconnect.yaml"), buildDataconnectYaml(config))) {
|
|
198
|
+
created.push("dataconnect/dataconnect.yaml");
|
|
199
|
+
}
|
|
200
|
+
if (await writeTextIfMissing(path.join(root, "schema", "schema.gql"), buildSchemaGql())) {
|
|
201
|
+
created.push("dataconnect/schema/schema.gql");
|
|
202
|
+
}
|
|
203
|
+
if (await writeTextIfMissing(path.join(root, "example", "queries.gql"), buildQueriesGql())) {
|
|
204
|
+
created.push("dataconnect/example/queries.gql");
|
|
205
|
+
}
|
|
206
|
+
if (await writeTextIfMissing(path.join(root, "example", "mutations.gql"), buildMutationsGql())) {
|
|
207
|
+
created.push("dataconnect/example/mutations.gql");
|
|
208
|
+
}
|
|
209
|
+
return created;
|
|
210
|
+
}
|
|
211
|
+
async function detectFunctionsEntry(targetDir) {
|
|
212
|
+
const candidates = [
|
|
213
|
+
"functions/src/index.ts",
|
|
214
|
+
"functions/src/index.js",
|
|
215
|
+
"functions/index.ts",
|
|
216
|
+
"functions/index.js",
|
|
217
|
+
"functions/index.mjs",
|
|
218
|
+
"functions/index.cjs"
|
|
219
|
+
];
|
|
220
|
+
for (const relativePath of candidates) {
|
|
221
|
+
const fullPath = path.join(targetDir, relativePath);
|
|
222
|
+
if (await exists(fullPath)) {
|
|
223
|
+
return relativePath;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
async function ensureFunctionsPackage(targetDir) {
|
|
229
|
+
const packagePath = path.join(targetDir, "functions", "package.json");
|
|
230
|
+
if (await exists(packagePath)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const packageJson = {
|
|
234
|
+
name: "functions",
|
|
235
|
+
private: true,
|
|
236
|
+
main: "index.js",
|
|
237
|
+
engines: {
|
|
238
|
+
node: "20"
|
|
239
|
+
},
|
|
240
|
+
scripts: {
|
|
241
|
+
lint: "echo \"No lint configured\"",
|
|
242
|
+
deploy: "firebase deploy --only functions",
|
|
243
|
+
serve: "firebase emulators:start --only functions",
|
|
244
|
+
shell: "firebase functions:shell",
|
|
245
|
+
start: "npm run shell"
|
|
246
|
+
},
|
|
247
|
+
dependencies: {
|
|
248
|
+
"firebase-admin": "^13.0.0",
|
|
249
|
+
"firebase-functions": "^6.0.0"
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
await writeJson(packagePath, packageJson);
|
|
253
|
+
await writeTextIfMissing(path.join(targetDir, "functions", ".gitignore"), "node_modules\n");
|
|
254
|
+
return "functions/package.json";
|
|
255
|
+
}
|
|
256
|
+
async function ensureFunctionsEntry(targetDir) {
|
|
257
|
+
const existing = await detectFunctionsEntry(targetDir);
|
|
258
|
+
if (existing) {
|
|
259
|
+
return { entryPath: existing, created: null };
|
|
260
|
+
}
|
|
261
|
+
const defaultEntry = "functions/index.js";
|
|
262
|
+
const fullPath = path.join(targetDir, defaultEntry);
|
|
263
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
264
|
+
await writeFile(fullPath, `const admin = require("firebase-admin");
|
|
265
|
+
admin.initializeApp();
|
|
266
|
+
`, "utf8");
|
|
267
|
+
return { entryPath: defaultEntry, created: defaultEntry };
|
|
268
|
+
}
|
|
269
|
+
async function ensureFunctionExport(targetDir, config, entryRelativePath) {
|
|
270
|
+
const functionsPackage = await readJsonIfExists(path.join(targetDir, "functions", "package.json"));
|
|
271
|
+
const entryFullPath = path.join(targetDir, entryRelativePath);
|
|
272
|
+
const moduleStyle = detectModuleStyle(entryFullPath, functionsPackage);
|
|
273
|
+
const extension = path.extname(entryRelativePath) || ".js";
|
|
274
|
+
const generatedFileName = `firestoreOnDocumentWritten${extension}`;
|
|
275
|
+
const generatedRelativePath = path.join(path.dirname(entryRelativePath), generatedFileName);
|
|
276
|
+
const generatedBaseName = path.basename(generatedFileName, extension);
|
|
277
|
+
const generatedImportPath = moduleStyle === "esm"
|
|
278
|
+
? extension === ".ts"
|
|
279
|
+
? `./${generatedBaseName}.js`
|
|
280
|
+
: `./${generatedFileName}`
|
|
281
|
+
: `./${generatedBaseName}`;
|
|
282
|
+
const generatedFullPath = path.join(targetDir, generatedRelativePath);
|
|
283
|
+
const functionModuleText = buildFunctionModule({
|
|
284
|
+
style: moduleStyle,
|
|
285
|
+
functionName: config.functionName,
|
|
286
|
+
documentPath: config.documentPath,
|
|
287
|
+
region: config.region
|
|
288
|
+
});
|
|
289
|
+
await writeFile(generatedFullPath, functionModuleText, "utf8");
|
|
290
|
+
const entryText = (await readFile(entryFullPath, "utf8")).trimEnd();
|
|
291
|
+
const exportLine = moduleStyle === "cjs"
|
|
292
|
+
? `exports.${config.functionName} = require("${generatedImportPath}").${config.functionName};`
|
|
293
|
+
: `export { ${config.functionName} } from "${generatedImportPath}";`;
|
|
294
|
+
const updated = appendIfMissing(entryText, exportLine);
|
|
295
|
+
if (updated.changed) {
|
|
296
|
+
await writeFile(entryFullPath, `${updated.content}`, "utf8");
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
generatedRelativePath: toPosixPath(generatedRelativePath),
|
|
300
|
+
entryRelativePath: toPosixPath(entryRelativePath),
|
|
301
|
+
entryUpdated: updated.changed
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async function runNpmInstall(functionsDir) {
|
|
305
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
306
|
+
const child = spawn("npm", ["install"], {
|
|
307
|
+
cwd: functionsDir,
|
|
308
|
+
stdio: "inherit"
|
|
309
|
+
});
|
|
310
|
+
child.on("error", rejectPromise);
|
|
311
|
+
child.on("exit", (code, signal) => {
|
|
312
|
+
if (code === 0) {
|
|
313
|
+
resolvePromise();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (code !== null) {
|
|
317
|
+
rejectPromise(new Error(`npm install failed with exit code ${code}`));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
rejectPromise(new Error(`npm install terminated by signal ${signal ?? "unknown signal"}`));
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
export async function run(config) {
|
|
325
|
+
if (!config.projectId) {
|
|
326
|
+
throw new Error("Firebase project id is required.");
|
|
327
|
+
}
|
|
328
|
+
const targetDir = path.resolve(config.targetDir);
|
|
329
|
+
const summaryLines = [];
|
|
330
|
+
summaryLines.push(`Updated ${await ensureFirebaseRc(targetDir, config.projectId)}`);
|
|
331
|
+
summaryLines.push(`Updated ${await ensureFirebaseJson(targetDir)}`);
|
|
332
|
+
const dataconnectCreated = await ensureDataConnectFiles(targetDir, config);
|
|
333
|
+
if (dataconnectCreated.length === 0) {
|
|
334
|
+
summaryLines.push("Data Connect files already existed");
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
summaryLines.push(`Created ${dataconnectCreated.join(", ")}`);
|
|
338
|
+
}
|
|
339
|
+
const functionsPackage = await ensureFunctionsPackage(targetDir);
|
|
340
|
+
if (functionsPackage) {
|
|
341
|
+
summaryLines.push(`Created ${functionsPackage}`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
summaryLines.push("functions/package.json already existed");
|
|
345
|
+
}
|
|
346
|
+
const { entryPath, created: createdEntry } = await ensureFunctionsEntry(targetDir);
|
|
347
|
+
if (createdEntry) {
|
|
348
|
+
summaryLines.push(`Created ${createdEntry}`);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
summaryLines.push(`Using existing ${entryPath}`);
|
|
352
|
+
}
|
|
353
|
+
const exportResult = await ensureFunctionExport(targetDir, config, entryPath);
|
|
354
|
+
summaryLines.push(`Updated ${exportResult.generatedRelativePath}`);
|
|
355
|
+
summaryLines.push(exportResult.entryUpdated
|
|
356
|
+
? `Appended export in ${exportResult.entryRelativePath}`
|
|
357
|
+
: `Export already present in ${exportResult.entryRelativePath}`);
|
|
358
|
+
if (config.installDependencies) {
|
|
359
|
+
const functionsDir = path.join(targetDir, "functions");
|
|
360
|
+
await runNpmInstall(functionsDir);
|
|
361
|
+
summaryLines.push("Installed functions dependencies with npm install");
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
summaryLines.push("Skipped npm install in functions directory");
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
targetDir,
|
|
368
|
+
summaryLines
|
|
369
|
+
};
|
|
370
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
function normalizeFunctionName(name) {
|
|
5
|
+
const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
6
|
+
if (!cleaned) {
|
|
7
|
+
return "onMeetingSummaryWritten";
|
|
8
|
+
}
|
|
9
|
+
if (/^[A-Za-z_$]/.test(cleaned)) {
|
|
10
|
+
return cleaned;
|
|
11
|
+
}
|
|
12
|
+
return `fn_${cleaned}`;
|
|
13
|
+
}
|
|
14
|
+
function normalizeConfig(partial) {
|
|
15
|
+
const targetDir = resolve(partial.targetDir ?? process.cwd());
|
|
16
|
+
const region = partial.region?.trim() || "us-central1";
|
|
17
|
+
const projectId = partial.projectId?.trim() || "";
|
|
18
|
+
const serviceId = partial.serviceId?.trim() || `${projectId || "my-project"}-service`;
|
|
19
|
+
const location = partial.location?.trim() || region;
|
|
20
|
+
const documentPath = partial.documentPath?.trim() || "meetingSummaries/{summaryId}";
|
|
21
|
+
const functionName = normalizeFunctionName(partial.functionName?.trim() || "onMeetingSummaryWritten");
|
|
22
|
+
const installDependencies = typeof partial.installDependencies === "boolean" ? partial.installDependencies : true;
|
|
23
|
+
return {
|
|
24
|
+
targetDir,
|
|
25
|
+
projectId,
|
|
26
|
+
region,
|
|
27
|
+
serviceId,
|
|
28
|
+
location,
|
|
29
|
+
documentPath,
|
|
30
|
+
functionName,
|
|
31
|
+
installDependencies
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function collectConfig(parsed) {
|
|
35
|
+
const defaults = normalizeConfig(parsed);
|
|
36
|
+
if (parsed.yes) {
|
|
37
|
+
if (!defaults.projectId) {
|
|
38
|
+
throw new Error("`--project` is required when using --yes.");
|
|
39
|
+
}
|
|
40
|
+
return defaults;
|
|
41
|
+
}
|
|
42
|
+
const rl = createInterface({ input, output });
|
|
43
|
+
try {
|
|
44
|
+
const ask = async (label, fallback, required = false) => {
|
|
45
|
+
const answer = await rl.question(`${label} [${fallback}]: `);
|
|
46
|
+
const value = answer.trim() || fallback;
|
|
47
|
+
if (!value && required) {
|
|
48
|
+
throw new Error(`${label} is required.`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
};
|
|
52
|
+
console.log("Firebase Data Connect / Cloud Functions bootstrap");
|
|
53
|
+
console.log("");
|
|
54
|
+
const targetDir = resolve(await ask("Target repository path", defaults.targetDir, true));
|
|
55
|
+
const projectId = await ask("Firebase project id", defaults.projectId || "my-project", true);
|
|
56
|
+
const region = await ask("Functions region", defaults.region, true);
|
|
57
|
+
const serviceId = await ask("Data Connect service id", `${projectId}-service`, true);
|
|
58
|
+
const location = await ask("Data Connect location", defaults.location || region, true);
|
|
59
|
+
const documentPath = await ask("Firestore document path", defaults.documentPath, true);
|
|
60
|
+
const rawFunctionName = await ask("Function export name", defaults.functionName, true);
|
|
61
|
+
const functionName = normalizeFunctionName(rawFunctionName);
|
|
62
|
+
const installRaw = await ask("Run npm install in functions directory? (y/n)", defaults.installDependencies ? "y" : "n", true);
|
|
63
|
+
const installDependencies = installRaw.toLowerCase().startsWith("y");
|
|
64
|
+
return {
|
|
65
|
+
targetDir,
|
|
66
|
+
projectId,
|
|
67
|
+
region,
|
|
68
|
+
serviceId,
|
|
69
|
+
location,
|
|
70
|
+
documentPath,
|
|
71
|
+
functionName,
|
|
72
|
+
installDependencies
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
rl.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "firebase-dataconnect-bootstrap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bootstrap Firebase Data Connect and Firestore onDocumentWritten function setup in any repository.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"firebase-dataconnect-bootstrap": "bin/firebase-dataconnect-bootstrap.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/main.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
19
|
+
"start": "npm run build && node dist/cli.js",
|
|
20
|
+
"lint": "npm run typecheck",
|
|
21
|
+
"test": "npm run typecheck",
|
|
22
|
+
"prepack": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"firebase",
|
|
26
|
+
"dataconnect",
|
|
27
|
+
"firestore",
|
|
28
|
+
"cloud-functions",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"author": "",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.10.2",
|
|
38
|
+
"typescript": "^5.7.2"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|