@wirechunk/cli 0.0.1-rc.1
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/build/main.js +70594 -0
- package/package.json +40 -0
- package/src/commands/bootstrap.ts +81 -0
- package/src/commands/create-extension-version.ts +206 -0
- package/src/commands/create-user.ts +142 -0
- package/src/commands/dev/init-ext-db.ts +263 -0
- package/src/commands/edit-admin.ts +69 -0
- package/src/env.ts +85 -0
- package/src/errors.ts +30 -0
- package/src/fetch.ts +32 -0
- package/src/global-options.ts +6 -0
- package/src/main.ts +114 -0
- package/src/users/permissions.ts +39 -0
- package/src/util.ts +29 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import type { DatabasePool } from 'slonik';
|
|
5
|
+
import { createPool, sql } from 'slonik';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { Env } from '../../env.js';
|
|
8
|
+
import { requireCoreDbUrl } from '../../env.ts';
|
|
9
|
+
import { isDuplicateDatabaseError } from '../../errors.ts';
|
|
10
|
+
import type { WithGlobalOptions } from '../../global-options.js';
|
|
11
|
+
import { dbPoolOptions, extensionDbName, requireExtensionIdOptionOrEnvVar } from '../../util.ts';
|
|
12
|
+
|
|
13
|
+
const initSchemas = async ({
|
|
14
|
+
extensionDbName,
|
|
15
|
+
extensionRoleName,
|
|
16
|
+
coreDbUrl,
|
|
17
|
+
db,
|
|
18
|
+
}: {
|
|
19
|
+
extensionDbName: string;
|
|
20
|
+
extensionRoleName: string;
|
|
21
|
+
coreDbUrl: URL;
|
|
22
|
+
db: DatabasePool;
|
|
23
|
+
}) => {
|
|
24
|
+
await db.query(sql.unsafe`
|
|
25
|
+
create extension if not exists postgres_fdw
|
|
26
|
+
`);
|
|
27
|
+
await db.query(sql.unsafe`
|
|
28
|
+
create server if not exists wirechunk
|
|
29
|
+
foreign data wrapper postgres_fdw
|
|
30
|
+
options (
|
|
31
|
+
host ${sql.literalValue(coreDbUrl.hostname)},
|
|
32
|
+
port ${sql.literalValue(coreDbUrl.port)},
|
|
33
|
+
dbname ${sql.literalValue(coreDbUrl.pathname.slice(1))}
|
|
34
|
+
)
|
|
35
|
+
`);
|
|
36
|
+
|
|
37
|
+
const extRoleNameIdent = sql.identifier([extensionRoleName]);
|
|
38
|
+
|
|
39
|
+
await db.query(sql.unsafe`
|
|
40
|
+
create user mapping if not exists for ${extRoleNameIdent} server wirechunk
|
|
41
|
+
options (user ${sql.literalValue(extensionRoleName)}, password_required 'false')
|
|
42
|
+
`);
|
|
43
|
+
await db.query(sql.unsafe`
|
|
44
|
+
create foreign table if not exists "Users" (
|
|
45
|
+
"id" uuid not null,
|
|
46
|
+
"firstName" text not null,
|
|
47
|
+
"lastName" text not null,
|
|
48
|
+
"email" text not null,
|
|
49
|
+
"emailVerified" boolean not null,
|
|
50
|
+
"orgId" text,
|
|
51
|
+
"role" text,
|
|
52
|
+
"status" text not null,
|
|
53
|
+
"expiresAt" timestamptz,
|
|
54
|
+
"createdAt" timestamptz not null
|
|
55
|
+
) server wirechunk options (schema_name 'public', table_name 'Users')
|
|
56
|
+
`);
|
|
57
|
+
await db.query(sql.unsafe`
|
|
58
|
+
create foreign table if not exists "Orgs" (
|
|
59
|
+
"id" text not null,
|
|
60
|
+
"name" text,
|
|
61
|
+
"primaryUserId" text,
|
|
62
|
+
"createdAt" timestamptz not null
|
|
63
|
+
) server wirechunk options (schema_name 'public', table_name 'Orgs')
|
|
64
|
+
`);
|
|
65
|
+
await db.query(sql.unsafe`
|
|
66
|
+
create foreign table if not exists "Sites" (
|
|
67
|
+
"id" uuid not null,
|
|
68
|
+
"domain" text not null,
|
|
69
|
+
"orgId" text,
|
|
70
|
+
"name" text not null,
|
|
71
|
+
"createdAt" timestamptz not null
|
|
72
|
+
) server wirechunk options (schema_name 'public', table_name 'Sites')
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
// These are not the permissions granted to actual extension roles in production.
|
|
76
|
+
// Here we just want to simplify the development experience.
|
|
77
|
+
// At any moment, a developer may drop an extension's database and reinitialize it.
|
|
78
|
+
await db.query(sql.unsafe`
|
|
79
|
+
grant connect, create, temporary on database ${sql.identifier([extensionDbName])} to ${extRoleNameIdent}
|
|
80
|
+
`);
|
|
81
|
+
await db.query(sql.unsafe`
|
|
82
|
+
grant all on schema public to ${extRoleNameIdent}
|
|
83
|
+
`);
|
|
84
|
+
await db.query(sql.unsafe`
|
|
85
|
+
grant all on table "Orgs" to ${extRoleNameIdent}
|
|
86
|
+
`);
|
|
87
|
+
await db.query(sql.unsafe`
|
|
88
|
+
grant all on table "Sites" to ${extRoleNameIdent}
|
|
89
|
+
`);
|
|
90
|
+
await db.query(sql.unsafe`
|
|
91
|
+
grant all on table "Users" to ${extRoleNameIdent}
|
|
92
|
+
`);
|
|
93
|
+
await db.query(sql.unsafe`
|
|
94
|
+
alter default privileges grant all on schemas to ${extRoleNameIdent}
|
|
95
|
+
`);
|
|
96
|
+
await db.query(sql.unsafe`
|
|
97
|
+
alter default privileges grant all on types to ${extRoleNameIdent}
|
|
98
|
+
`);
|
|
99
|
+
await db.query(sql.unsafe`
|
|
100
|
+
alter default privileges grant all on tables to ${extRoleNameIdent}
|
|
101
|
+
`);
|
|
102
|
+
await db.query(sql.unsafe`
|
|
103
|
+
alter default privileges grant all on sequences to ${extRoleNameIdent}
|
|
104
|
+
`);
|
|
105
|
+
await db.query(sql.unsafe`
|
|
106
|
+
alter default privileges grant all on functions to ${extRoleNameIdent}
|
|
107
|
+
`);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const replaceDbName = (url: URL, dbName: string): URL => {
|
|
111
|
+
const urlObj = new URL(url.toString());
|
|
112
|
+
urlObj.pathname = `/${dbName}`;
|
|
113
|
+
return urlObj;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const extensionSelectSchema = z.object({
|
|
117
|
+
id: z.string(),
|
|
118
|
+
platformId: z.string(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Replace instances of a variable, or add a variable if needed, preserving other lines.
|
|
122
|
+
const replaceEnvVar = (lines: string[], name: string, value: string): string[] => {
|
|
123
|
+
const newLines: string[] = [];
|
|
124
|
+
let hasName = false;
|
|
125
|
+
const namePattern = new RegExp(String.raw`^\s*${name}\s*=\s*`);
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
if (namePattern.test(line)) {
|
|
128
|
+
if (!hasName) {
|
|
129
|
+
newLines.push(`${name}=${value}`);
|
|
130
|
+
hasName = true;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
newLines.push(line);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!hasName) {
|
|
137
|
+
newLines.push(`${name}=${value}`);
|
|
138
|
+
}
|
|
139
|
+
return newLines;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const applyExtensionPolicy = async ({
|
|
143
|
+
extRole,
|
|
144
|
+
table,
|
|
145
|
+
platformId,
|
|
146
|
+
db,
|
|
147
|
+
}: {
|
|
148
|
+
extRole: string;
|
|
149
|
+
table: string;
|
|
150
|
+
platformId: string;
|
|
151
|
+
db: DatabasePool;
|
|
152
|
+
}) => {
|
|
153
|
+
const policyNameIdent = sql.identifier([`${table}_ext_${extRole}_select`]);
|
|
154
|
+
const extRoleIdent = sql.identifier([extRole]);
|
|
155
|
+
const tableIdent = sql.identifier([table]);
|
|
156
|
+
await db.query(sql.unsafe`
|
|
157
|
+
drop policy if exists ${policyNameIdent} on ${tableIdent}
|
|
158
|
+
`);
|
|
159
|
+
await db.query(sql.unsafe`
|
|
160
|
+
create policy ${policyNameIdent} on ${tableIdent} as permissive
|
|
161
|
+
for select to ${extRoleIdent}
|
|
162
|
+
using ("platformId" = ${sql.literalValue(platformId)})
|
|
163
|
+
`);
|
|
164
|
+
await db.query(sql.unsafe`
|
|
165
|
+
grant select on ${tableIdent} to ${extRoleIdent}
|
|
166
|
+
`);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
type InitExtDbOptions = {
|
|
170
|
+
extensionId?: string;
|
|
171
|
+
dbName?: string;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const initExtDb = async (
|
|
175
|
+
opts: WithGlobalOptions<InitExtDbOptions>,
|
|
176
|
+
env: Env,
|
|
177
|
+
): Promise<void> => {
|
|
178
|
+
const coreDbUrl = requireCoreDbUrl(env);
|
|
179
|
+
const extensionId = requireExtensionIdOptionOrEnvVar(opts, env);
|
|
180
|
+
const db = await createPool(coreDbUrl, dbPoolOptions(opts));
|
|
181
|
+
|
|
182
|
+
const extension = await db.maybeOne(sql.type(extensionSelectSchema)`
|
|
183
|
+
select "id", "platformId"
|
|
184
|
+
from "Extensions"
|
|
185
|
+
where "id" = ${extensionId}
|
|
186
|
+
`);
|
|
187
|
+
if (!extension) {
|
|
188
|
+
console.error(`Extension with ID ${extensionId} not found`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const extDb = opts.dbName || extensionDbName(extensionId);
|
|
193
|
+
|
|
194
|
+
const coreDbUrlObject = new URL(coreDbUrl);
|
|
195
|
+
const extDbUrl = replaceDbName(coreDbUrlObject, extDb);
|
|
196
|
+
extDbUrl.pathname = `/${extDb}`;
|
|
197
|
+
|
|
198
|
+
const cwd = process.cwd();
|
|
199
|
+
const localEnvFilePath = opts.envMode
|
|
200
|
+
? resolve(cwd, `./.env.${opts.envMode}.local`)
|
|
201
|
+
: resolve(cwd, './.env.local');
|
|
202
|
+
if (opts.verbose) {
|
|
203
|
+
console.log(`Loading ${localEnvFilePath} to update environment variables`);
|
|
204
|
+
}
|
|
205
|
+
if (existsSync(localEnvFilePath)) {
|
|
206
|
+
const localEnv = await readFile(localEnvFilePath, 'utf8');
|
|
207
|
+
const lines = replaceEnvVar(localEnv.split('\n'), 'DATABASE_URL', extDbUrl.toString());
|
|
208
|
+
await writeFile(localEnvFilePath, lines.join('\n'));
|
|
209
|
+
} else {
|
|
210
|
+
await writeFile(localEnvFilePath, `DATABASE_URL=${extDbUrl.toString()}\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await db.query(sql.unsafe`
|
|
215
|
+
create database ${sql.identifier([extDb])}
|
|
216
|
+
`);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (!isDuplicateDatabaseError(err)) {
|
|
219
|
+
console.error('Failed to create a database:', err);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const extRole = `ext_${extension.id}`;
|
|
225
|
+
|
|
226
|
+
const extRoleIdent = sql.identifier([extRole]);
|
|
227
|
+
await db.query(sql.unsafe`
|
|
228
|
+
do $$
|
|
229
|
+
begin
|
|
230
|
+
if not exists (
|
|
231
|
+
select 1 from pg_roles where rolname = ${sql.literalValue(extRole)}
|
|
232
|
+
) then
|
|
233
|
+
create role ${extRoleIdent} login noinherit;
|
|
234
|
+
end if;
|
|
235
|
+
end; $$
|
|
236
|
+
`);
|
|
237
|
+
|
|
238
|
+
await applyExtensionPolicy({
|
|
239
|
+
extRole,
|
|
240
|
+
table: 'Orgs',
|
|
241
|
+
platformId: extension.platformId,
|
|
242
|
+
db,
|
|
243
|
+
});
|
|
244
|
+
await applyExtensionPolicy({
|
|
245
|
+
extRole,
|
|
246
|
+
table: 'Sites',
|
|
247
|
+
platformId: extension.platformId,
|
|
248
|
+
db,
|
|
249
|
+
});
|
|
250
|
+
await applyExtensionPolicy({
|
|
251
|
+
extRole,
|
|
252
|
+
table: 'Users',
|
|
253
|
+
platformId: extension.platformId,
|
|
254
|
+
db,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await initSchemas({
|
|
258
|
+
extensionDbName: extDb,
|
|
259
|
+
extensionRoleName: extRole,
|
|
260
|
+
coreDbUrl: coreDbUrlObject,
|
|
261
|
+
db: await createPool(extDbUrl.toString(), dbPoolOptions(opts)),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createPool, sql, UniqueIntegrityConstraintViolationError } from 'slonik';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { Env } from '../env.ts';
|
|
4
|
+
import { requireCoreDbUrl } from '../env.ts';
|
|
5
|
+
import { detailedUniqueIntegrityConstraintViolationError } from '../errors.ts';
|
|
6
|
+
import type { WithGlobalOptions } from '../global-options.ts';
|
|
7
|
+
|
|
8
|
+
const findUserSchema = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
platformId: z.string(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
type EditAdminOptions = {
|
|
14
|
+
platformId: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
owner?: boolean;
|
|
17
|
+
revokeAllPermissions?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const editAdmin = async (
|
|
21
|
+
opts: WithGlobalOptions<EditAdminOptions>,
|
|
22
|
+
env: Env,
|
|
23
|
+
): Promise<void> => {
|
|
24
|
+
const db = await createPool(requireCoreDbUrl(env));
|
|
25
|
+
const { platformId, userId, owner, revokeAllPermissions } = opts;
|
|
26
|
+
|
|
27
|
+
if (owner && revokeAllPermissions) {
|
|
28
|
+
console.error(
|
|
29
|
+
'Cannot set a user as a platform owner and revoke all permissions at the same time',
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await db.transaction(async (db) => {
|
|
36
|
+
const platformAdmin = await db.maybeOne(
|
|
37
|
+
sql.type(
|
|
38
|
+
findUserSchema,
|
|
39
|
+
)`select "id" from "PlatformAdmins" where "platformId" = ${platformId} and "userId" = ${userId}`,
|
|
40
|
+
);
|
|
41
|
+
if (!platformAdmin) {
|
|
42
|
+
const user = await db.maybeOne(
|
|
43
|
+
sql.type(findUserSchema)`select "id" from "Users" where "id" = ${userId}`,
|
|
44
|
+
);
|
|
45
|
+
if (!user) {
|
|
46
|
+
throw new Error(`User with ID ${userId} not found`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// TODO
|
|
51
|
+
|
|
52
|
+
// if (owner) {
|
|
53
|
+
// await grantAllUserPlatformPermissions({ userId: id, platformId: user.platformId }, db);
|
|
54
|
+
// console.log('Set user as a platform admin');
|
|
55
|
+
// }
|
|
56
|
+
// if (revokeAllPermissions) {
|
|
57
|
+
// await revokeAllUserPlatformPermissions({ userId: id }, db);
|
|
58
|
+
// console.log('Revoked all platform permissions of user');
|
|
59
|
+
// }
|
|
60
|
+
});
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e instanceof UniqueIntegrityConstraintViolationError) {
|
|
63
|
+
console.error(detailedUniqueIntegrityConstraintViolationError(e));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.error(e);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
};
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { parseEnv as parseEnvContents } from 'node:util';
|
|
5
|
+
import { isLocalhost } from '@wirechunk/lib/localhost.ts';
|
|
6
|
+
|
|
7
|
+
export type Env = Record<string, string> & {
|
|
8
|
+
CORE_DATABASE_URL?: string;
|
|
9
|
+
// A URL to the server root, like https://wirechunk.com or http://admin.localhost:8080
|
|
10
|
+
CORE_SERVER_URL: string;
|
|
11
|
+
WIRECHUNK_API_TOKEN?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// A .env file is required only if the required environment variables are not already set.
|
|
15
|
+
const envFilePath = '.env';
|
|
16
|
+
const envLocalFilePath = '.env.local';
|
|
17
|
+
|
|
18
|
+
const hasEnvFile = existsSync(envFilePath);
|
|
19
|
+
|
|
20
|
+
export const requireApiToken = (env: Env): string => {
|
|
21
|
+
if (!env.WIRECHUNK_API_TOKEN) {
|
|
22
|
+
console.error(
|
|
23
|
+
'Missing API token for authentication. Provide a WIRECHUNK_API_TOKEN environment variable.',
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
return env.WIRECHUNK_API_TOKEN;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const requireCoreDbUrl = (env: Env): string => {
|
|
31
|
+
if (!env.CORE_DATABASE_URL) {
|
|
32
|
+
if (hasEnvFile) {
|
|
33
|
+
console.error('Missing CORE_DATABASE_URL in environment');
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`Missing CORE_DATABASE_URL in environment, no ${envFilePath} file found`);
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
return env.CORE_DATABASE_URL;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const coreServerUrlFromEnv = (env: Record<string, string>): string => {
|
|
43
|
+
const url = env.CORE_SERVER_URL;
|
|
44
|
+
if (url) {
|
|
45
|
+
return url.endsWith('/api') ? url.substring(0, url.length - 4) : url;
|
|
46
|
+
}
|
|
47
|
+
const adminDomain = env.ADMIN_DOMAIN;
|
|
48
|
+
if (adminDomain) {
|
|
49
|
+
return isLocalhost(adminDomain) ? `http://${adminDomain}` : `https://${adminDomain}`;
|
|
50
|
+
}
|
|
51
|
+
return 'https://wirechunk.com';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const parseEnv = async (envMode: string | undefined): Promise<Env> => {
|
|
55
|
+
const env: Record<string, string> = {};
|
|
56
|
+
|
|
57
|
+
if (hasEnvFile) {
|
|
58
|
+
const envFileRaw = await readFile(envFilePath, 'utf8');
|
|
59
|
+
Object.assign(env, parseEnvContents(envFileRaw));
|
|
60
|
+
}
|
|
61
|
+
if (envMode) {
|
|
62
|
+
const modeFilePath = `.env.${envMode}`;
|
|
63
|
+
if (existsSync(modeFilePath)) {
|
|
64
|
+
const modeFileRaw = await readFile(modeFilePath, 'utf8');
|
|
65
|
+
Object.assign(env, parseEnvContents(modeFileRaw));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (existsSync(envLocalFilePath)) {
|
|
69
|
+
const envLocalFileRaw = await readFile(envLocalFilePath, 'utf8');
|
|
70
|
+
Object.assign(env, parseEnvContents(envLocalFileRaw));
|
|
71
|
+
}
|
|
72
|
+
if (envMode) {
|
|
73
|
+
const modeLocalFilePath = `.env.${envMode}.local`;
|
|
74
|
+
if (existsSync(modeLocalFilePath)) {
|
|
75
|
+
const modeLocalFileRaw = await readFile(modeLocalFilePath, 'utf8');
|
|
76
|
+
Object.assign(env, parseEnvContents(modeLocalFileRaw));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
Object.assign(env, process.env);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...env,
|
|
83
|
+
CORE_SERVER_URL: coreServerUrlFromEnv(env),
|
|
84
|
+
};
|
|
85
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { UniqueIntegrityConstraintViolationError } from 'slonik';
|
|
2
|
+
|
|
3
|
+
export const detailedUniqueIntegrityConstraintViolationError = (
|
|
4
|
+
error: UniqueIntegrityConstraintViolationError,
|
|
5
|
+
): string => {
|
|
6
|
+
// The default error message is not very helpful, so we provide a more specific one.
|
|
7
|
+
const message =
|
|
8
|
+
error.message === 'Query violates a unique integrity constraint.'
|
|
9
|
+
? `A database uniqueness constraint was violated when inserting or updating`
|
|
10
|
+
: error.message;
|
|
11
|
+
const details: string[] = [];
|
|
12
|
+
if (error.table) {
|
|
13
|
+
details.push(`table "${error.table}"`);
|
|
14
|
+
}
|
|
15
|
+
if (error.column) {
|
|
16
|
+
details.push(`column "${error.column}"`);
|
|
17
|
+
}
|
|
18
|
+
if (error.constraint) {
|
|
19
|
+
details.push(`constraint "${error.constraint}"`);
|
|
20
|
+
}
|
|
21
|
+
if (details.length) {
|
|
22
|
+
return `${message} (${details.join(', ')})`;
|
|
23
|
+
}
|
|
24
|
+
return message;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Returns true if error is an object with a code property that signifies there is a duplicate database.
|
|
28
|
+
// Note the expected shape comes from the 'pg' library.
|
|
29
|
+
export const isDuplicateDatabaseError = (error: unknown): boolean =>
|
|
30
|
+
!!error && typeof error === 'object' && 'code' in error && error.code === '42P04';
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { lookup } from 'node:dns';
|
|
2
|
+
import { isLocalhost } from '@wirechunk/lib/localhost.ts';
|
|
3
|
+
import * as undici from 'undici';
|
|
4
|
+
|
|
5
|
+
// To allow easily accessing localhost subdomains without needing to override /etc/hosts, this function provides a DNS resolver
|
|
6
|
+
// that resolves to 127.0.0.1 for localhost domains.
|
|
7
|
+
export const fetchWithLocal = (
|
|
8
|
+
url: string,
|
|
9
|
+
init: RequestInit & undici.RequestInit,
|
|
10
|
+
): Promise<Response | undici.Response> => {
|
|
11
|
+
const { host } = new URL(url);
|
|
12
|
+
if (isLocalhost(host)) {
|
|
13
|
+
return undici.fetch(url, {
|
|
14
|
+
...init,
|
|
15
|
+
dispatcher: new undici.Agent({
|
|
16
|
+
connect: {
|
|
17
|
+
lookup: (hostname, options, callback) => {
|
|
18
|
+
if (isLocalhost(hostname)) {
|
|
19
|
+
callback(null, [
|
|
20
|
+
{ address: '127.0.0.1', family: 4 },
|
|
21
|
+
{ address: '::1', family: 6 },
|
|
22
|
+
]);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
lookup(hostname, options, callback);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return fetch(url, init);
|
|
32
|
+
};
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import type { OptionValues } from '@commander-js/extra-typings';
|
|
4
|
+
import { Command, Option } from '@commander-js/extra-typings';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { bootstrap } from './commands/bootstrap.ts';
|
|
7
|
+
import { createExtensionVersion } from './commands/create-extension-version.ts';
|
|
8
|
+
import { createUser } from './commands/create-user.ts';
|
|
9
|
+
import { initExtDb } from './commands/dev/init-ext-db.ts';
|
|
10
|
+
import { editAdmin } from './commands/edit-admin.ts';
|
|
11
|
+
import type { Env } from './env.ts';
|
|
12
|
+
import { parseEnv } from './env.ts';
|
|
13
|
+
import type { WithGlobalOptions } from './global-options.ts';
|
|
14
|
+
|
|
15
|
+
const program = new Command()
|
|
16
|
+
.name('wirechunk')
|
|
17
|
+
.option('--verbose', 'output debug logging')
|
|
18
|
+
.option(
|
|
19
|
+
'--env-mode <mode>',
|
|
20
|
+
'the mode to use for finding .env files to load environment variables, such as "test" to load .env, .env.test, .env.local, and .env.test.local',
|
|
21
|
+
).description(`The official Wirechunk CLI
|
|
22
|
+
|
|
23
|
+
By default, environment variables are loaded from the .env file in the current working directory,
|
|
24
|
+
then the .env.local file, and then from the environment. Variables from the environment have the
|
|
25
|
+
highest precedence.
|
|
26
|
+
|
|
27
|
+
Environment variables used by some commands:
|
|
28
|
+
CORE_SERVER_URL (the core admin server URL for commands using the API)
|
|
29
|
+
CORE_DATABASE_URL (the core database URL for commands requiring direct database access)`);
|
|
30
|
+
|
|
31
|
+
const withOptionsAndEnv =
|
|
32
|
+
<Args extends string[], Options extends OptionValues>(
|
|
33
|
+
action: (options: WithGlobalOptions<Options>, env: Env) => Promise<void> | void,
|
|
34
|
+
) =>
|
|
35
|
+
async (options: Options, cmd: Command<Args, Options>) => {
|
|
36
|
+
const mergedOptions = { ...program.opts(), ...options };
|
|
37
|
+
if (mergedOptions.verbose) {
|
|
38
|
+
console.log(`Running ${chalk.green.bold(cmd.name())}`);
|
|
39
|
+
}
|
|
40
|
+
const env = await parseEnv(mergedOptions.envMode);
|
|
41
|
+
return action(mergedOptions, env);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('bootstrap')
|
|
46
|
+
.description('create a platform')
|
|
47
|
+
.requiredOption('--name <string>', 'the name of the platform')
|
|
48
|
+
.option('--handle <string>', 'the handle of the platform (used by the admin site)')
|
|
49
|
+
.requiredOption(
|
|
50
|
+
'--admin-site-domain <string>',
|
|
51
|
+
'the domain of the platform admin site (dashboard)',
|
|
52
|
+
)
|
|
53
|
+
.option(
|
|
54
|
+
'--email-send-from <string>',
|
|
55
|
+
'the email address from which to send emails, defaults to "site@<admin-site-domain>',
|
|
56
|
+
)
|
|
57
|
+
.action(withOptionsAndEnv(bootstrap));
|
|
58
|
+
|
|
59
|
+
program
|
|
60
|
+
.command('create-extension-version')
|
|
61
|
+
.description('create an extension version')
|
|
62
|
+
.option(
|
|
63
|
+
'--extension-id <string>',
|
|
64
|
+
'the ID of the extension, can be set with an EXTENSION_ID environment variable instead',
|
|
65
|
+
)
|
|
66
|
+
.requiredOption('--version-name <string>', 'the name of the version')
|
|
67
|
+
.action(withOptionsAndEnv(createExtensionVersion));
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command('create-user')
|
|
71
|
+
.description('create a user')
|
|
72
|
+
.requiredOption('--email <string>', 'the email address of the user')
|
|
73
|
+
.requiredOption('--password <string>', 'the password of the user')
|
|
74
|
+
.requiredOption('--first-name <string>', 'the first name of the user')
|
|
75
|
+
.requiredOption('--last-name <string>', 'the last name of the user')
|
|
76
|
+
.option(
|
|
77
|
+
'--org-id <string>',
|
|
78
|
+
'the ID of the org to which the user belongs, defaults to creating a new org if not specified',
|
|
79
|
+
)
|
|
80
|
+
.option(
|
|
81
|
+
'--platform-id <string>',
|
|
82
|
+
'the ID of the platform ID to which the users will be added, used only if org ID is not specified',
|
|
83
|
+
)
|
|
84
|
+
.addOption(
|
|
85
|
+
new Option('--role <string>', 'the role the user will have').default('OrganizationOwner'),
|
|
86
|
+
)
|
|
87
|
+
.option('--email-verified', 'mark the email address as already verified by the user', false)
|
|
88
|
+
.option('--pending', 'create the user in a pending state', false)
|
|
89
|
+
.action(withOptionsAndEnv(createUser));
|
|
90
|
+
|
|
91
|
+
// TODO: create-admin
|
|
92
|
+
|
|
93
|
+
const dev = program.command('dev').description('extension development commands');
|
|
94
|
+
|
|
95
|
+
dev
|
|
96
|
+
.command('init-ext-db')
|
|
97
|
+
.description('initialize a development database for an extension')
|
|
98
|
+
.option(
|
|
99
|
+
'--extension-id <string>',
|
|
100
|
+
'the ID of the extension, can be set with an EXTENSION_ID environment variable instead',
|
|
101
|
+
)
|
|
102
|
+
.option('--db-name <string>', 'a custom name for the database, applicable only for testing')
|
|
103
|
+
.action(withOptionsAndEnv(initExtDb));
|
|
104
|
+
|
|
105
|
+
program
|
|
106
|
+
.command('edit-admin')
|
|
107
|
+
.description('edit a platform admin user')
|
|
108
|
+
.requiredOption('--platform-id <string>', 'the ID of the platform to edit')
|
|
109
|
+
.requiredOption('--user-id <string>', 'the ID of the admin user to edit')
|
|
110
|
+
.option('--owner', 'grants the user full permission to manage everything on the platform')
|
|
111
|
+
.option('--revoke-all-permissions', 'revokes all permission of the user on their platform')
|
|
112
|
+
.action(withOptionsAndEnv(editAdmin));
|
|
113
|
+
|
|
114
|
+
await program.parseAsync();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Permission } from '@wirechunk/lib/graphql-api-enums.ts';
|
|
2
|
+
import type { CommonQueryMethods } from 'slonik';
|
|
3
|
+
import { sql } from 'slonik';
|
|
4
|
+
import { voidSelectSchema } from '../util.ts';
|
|
5
|
+
|
|
6
|
+
export const allPermissions = Object.values(Permission);
|
|
7
|
+
|
|
8
|
+
export const revokeAllUserPlatformPermissions = async (
|
|
9
|
+
{
|
|
10
|
+
userId,
|
|
11
|
+
}: {
|
|
12
|
+
userId: string;
|
|
13
|
+
},
|
|
14
|
+
db: CommonQueryMethods,
|
|
15
|
+
): Promise<void> => {
|
|
16
|
+
await db.query(
|
|
17
|
+
sql.type(voidSelectSchema)`delete from "UserPlatformPermissions" where "userId" = ${userId}`,
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const grantAllUserPlatformPermissions = async (
|
|
22
|
+
{
|
|
23
|
+
userId,
|
|
24
|
+
platformId,
|
|
25
|
+
}: {
|
|
26
|
+
userId: string;
|
|
27
|
+
platformId: string;
|
|
28
|
+
},
|
|
29
|
+
db: CommonQueryMethods,
|
|
30
|
+
): Promise<void> => {
|
|
31
|
+
await db.query(
|
|
32
|
+
sql.type(
|
|
33
|
+
voidSelectSchema,
|
|
34
|
+
)`insert into "UserPlatformPermissions" ("userId", "platformId", "permission") values ${sql.join(
|
|
35
|
+
allPermissions.map((permission) => sql.fragment`(${userId}, ${platformId}, ${permission})`),
|
|
36
|
+
sql.fragment`,`,
|
|
37
|
+
)} on conflict on constraint "UserPlatformPermissions_pkey" do nothing`,
|
|
38
|
+
);
|
|
39
|
+
};
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ClientConfigurationInput } from 'slonik/src/types.js';
|
|
2
|
+
import { createQueryLoggingInterceptor } from 'slonik-interceptor-query-logging';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { Env } from './env.ts';
|
|
5
|
+
import type { GlobalOptions } from './global-options.ts';
|
|
6
|
+
|
|
7
|
+
// Returns the name to use for an extension's database in development mode.
|
|
8
|
+
export const extensionDbName = (id: string): string => `ext_${id}`;
|
|
9
|
+
|
|
10
|
+
export const voidSelectSchema = z.object({});
|
|
11
|
+
|
|
12
|
+
export const requireExtensionIdOptionOrEnvVar = (
|
|
13
|
+
options: { extensionId?: string },
|
|
14
|
+
env: Env,
|
|
15
|
+
): string => {
|
|
16
|
+
const extensionId = options.extensionId || env.EXTENSION_ID;
|
|
17
|
+
if (!extensionId) {
|
|
18
|
+
console.error(
|
|
19
|
+
'Missing an extension ID, must be specified either as the --extension-id argument or the EXTENSION_ID environment variable',
|
|
20
|
+
);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
return extensionId;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Returns the value for the interceptors property to use on a Slonik ClientConfigurationInput.
|
|
27
|
+
// Note that you still need to set a ROARR_LOG=true environment variable to enable logging.
|
|
28
|
+
export const dbPoolOptions = (opts: GlobalOptions): ClientConfigurationInput =>
|
|
29
|
+
opts.verbose ? { interceptors: [createQueryLoggingInterceptor({ logValues: true })] } : {};
|