@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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@wirechunk/cli",
3
+ "version": "0.0.1-rc.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "scripts": {
7
+ "lint": "eslint --fix",
8
+ "lint:check": "eslint",
9
+ "format": "prettier --write .",
10
+ "format:check": "prettier --check .",
11
+ "typecheck": "tsc",
12
+ "typecheck-src": "tsc --skipLibCheck",
13
+ "test": "echo 'no tests yet'",
14
+ "build": "npm run build:clean && npm run build:cli",
15
+ "build:clean": "rm -rf build",
16
+ "build:cli": "vite build"
17
+ },
18
+ "bin": {
19
+ "wirechunk": "build/main.js"
20
+ },
21
+ "dependencies": {
22
+ "argon2": "^0.41.1"
23
+ },
24
+ "devDependencies": {
25
+ "@commander-js/extra-typings": "^13.0.0",
26
+ "@types/archiver": "^6.0.3",
27
+ "@wirechunk/backend-lib": "0.0.0",
28
+ "@wirechunk/lib": "0.0.0",
29
+ "archiver": "^7.0.1",
30
+ "chalk": "^5.3.0",
31
+ "commander": "^13.0.0",
32
+ "slonik": "^46.3.0",
33
+ "slonik-interceptor-query-logging": "^46.3.0",
34
+ "undici": "^7.2.0",
35
+ "zod": "^3.24.1"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { cleanTinyId } from '@wirechunk/lib/clean-small-id.ts';
3
+ import { normalizeEmailAddress } from '@wirechunk/lib/emails.ts';
4
+ import {
5
+ defaultFormattedDataTemplate,
6
+ defaultNotificationEmailBodyTemplate,
7
+ } from '@wirechunk/lib/mixer/form-formatting-templates.ts';
8
+ import { normalizeDomain } from '@wirechunk/server/site-domains/util.ts';
9
+ import type { DatabasePool } from 'slonik';
10
+ import { createPool, sql } from 'slonik';
11
+ import type { Env } from '../env.ts';
12
+ import { requireCoreDbUrl } from '../env.ts';
13
+ import type { WithGlobalOptions } from '../global-options.ts';
14
+ import { voidSelectSchema } from '../util.ts';
15
+
16
+ const platformHandleAvailable = async (handle: string, db: DatabasePool) =>
17
+ !(await db.maybeOne(
18
+ sql.type(voidSelectSchema)`select from "Platforms" where "handle" = ${handle}`,
19
+ ));
20
+
21
+ const randomString = () => randomUUID().replaceAll('-', '').slice(0, 10);
22
+
23
+ type BootstrapOptions = {
24
+ name: string;
25
+ handle?: string;
26
+ adminSiteDomain: string;
27
+ emailSendFrom?: string;
28
+ };
29
+
30
+ export const bootstrap = async (
31
+ opts: WithGlobalOptions<BootstrapOptions>,
32
+ env: Env,
33
+ ): Promise<void> => {
34
+ const db = await createPool(requireCoreDbUrl(env));
35
+
36
+ const platformId = cleanTinyId();
37
+ const name = opts.name.trim();
38
+ let handle = opts.handle?.trim().toLowerCase().replace(/\s+/g, '-');
39
+ if (!handle) {
40
+ handle = name.toLowerCase().replace(/\s+/g, '-');
41
+ if (!(await platformHandleAvailable(handle, db))) {
42
+ handle = `${handle}-${randomString()}`;
43
+ }
44
+ } else if (!(await platformHandleAvailable(handle, db))) {
45
+ console.error(`Handle "${handle}" is already in use`);
46
+ process.exit(1);
47
+ }
48
+ const adminSiteDomain = normalizeDomain(opts.adminSiteDomain, { allowPort: true });
49
+ if (!adminSiteDomain) {
50
+ console.error('Invalid admin site domain');
51
+ process.exit(1);
52
+ }
53
+ let emailSendFrom: string;
54
+ if (opts.emailSendFrom) {
55
+ const normalizeEmailSendFromResult = normalizeEmailAddress(opts.emailSendFrom);
56
+ if (!normalizeEmailSendFromResult.ok) {
57
+ console.error(normalizeEmailSendFromResult.error);
58
+ process.exit(1);
59
+ }
60
+ emailSendFrom = normalizeEmailSendFromResult.value;
61
+ } else {
62
+ emailSendFrom = `site@${adminSiteDomain}`;
63
+ }
64
+ const adminSiteName = `${name} Admin`;
65
+
66
+ await db.transaction(async (db) => {
67
+ await db.query(
68
+ sql.type(
69
+ voidSelectSchema,
70
+ )`insert into "Platforms" ("id", "handle", "name", "defaultFormFormattedDataTemplate", "defaultFormNotificationEmailBodyTemplate", "emailSendFromAddress") values (${platformId}, ${handle}, ${name}, ${defaultFormattedDataTemplate}, ${defaultNotificationEmailBodyTemplate}, ${emailSendFrom})`,
71
+ );
72
+
73
+ await db.query(
74
+ sql.type(
75
+ voidSelectSchema,
76
+ )`insert into "Sites" ("platformId", "domain", "name") values (${platformId}, ${adminSiteDomain}, ${adminSiteName})`,
77
+ );
78
+ });
79
+
80
+ console.log(`Created platform ${name} with handle ${handle} (ID ${platformId})`);
81
+ };
@@ -0,0 +1,206 @@
1
+ import { parseErrorMessage } from '@wirechunk/lib/errors.ts';
2
+ import archiver from 'archiver';
3
+ import { z } from 'zod';
4
+ import type { Env } from '../env.ts';
5
+ import { requireApiToken } from '../env.ts';
6
+ import { fetchWithLocal } from '../fetch.ts';
7
+ import type { WithGlobalOptions } from '../global-options.js';
8
+ import { requireExtensionIdOptionOrEnvVar } from '../util.ts';
9
+
10
+ const bytesFormat = Intl.NumberFormat('en', {
11
+ notation: 'compact',
12
+ style: 'unit',
13
+ unit: 'byte',
14
+ unitDisplay: 'narrow',
15
+ minimumFractionDigits: 1,
16
+ maximumFractionDigits: 1,
17
+ });
18
+
19
+ const createExtensionVersionResultDataSchema = z.discriminatedUnion('__typename', [
20
+ z.object({
21
+ __typename: z.literal('CreateExtensionVersionSuccessResult'),
22
+ extensionVersion: z.object({
23
+ id: z.string(),
24
+ }),
25
+ signedUrl: z.string(),
26
+ }),
27
+ z.object({
28
+ __typename: z.literal('AuthorizationError'),
29
+ message: z.string(),
30
+ }),
31
+ z.object({
32
+ __typename: z.literal('GenericInternalError'),
33
+ message: z.string(),
34
+ }),
35
+ z.object({
36
+ __typename: z.literal('GenericUserError'),
37
+ message: z.string(),
38
+ }),
39
+ ]);
40
+
41
+ const graphQlErrorSchema = z.object({
42
+ message: z.string(),
43
+ });
44
+
45
+ const createExtensionVersionResultSchema = z.object({
46
+ errors: z.array(graphQlErrorSchema).optional().nullable(),
47
+ data: z
48
+ .object({
49
+ createExtensionVersion: createExtensionVersionResultDataSchema,
50
+ })
51
+ .nullable(),
52
+ });
53
+
54
+ type CreateExtensionVersionOptions = {
55
+ extensionId?: string;
56
+ versionName: string;
57
+ };
58
+
59
+ export const createExtensionVersion = async (
60
+ opts: WithGlobalOptions<CreateExtensionVersionOptions>,
61
+ env: Env,
62
+ ): Promise<void> => {
63
+ const extensionId = requireExtensionIdOptionOrEnvVar(opts, env);
64
+ const apiToken = requireApiToken(env);
65
+ let extensionVersionId: string;
66
+ let signedUrl: string;
67
+ try {
68
+ const url = `${env.CORE_SERVER_URL}/api`;
69
+ if (opts.verbose) {
70
+ console.log(`POST ${url}`);
71
+ }
72
+ const createResult = await fetchWithLocal(url, {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ Authorization: `Bearer ${apiToken}`,
77
+ },
78
+ body: JSON.stringify({
79
+ query: `
80
+ mutation ($input: CreateExtensionVersionInput!) {
81
+ createExtensionVersion(input: $input) {
82
+ __typename
83
+ ... on CreateExtensionVersionSuccessResult {
84
+ extensionVersion {
85
+ id
86
+ }
87
+ signedUrl
88
+ }
89
+ ... on Error {
90
+ message
91
+ }
92
+ }
93
+ }
94
+ `,
95
+ variables: {
96
+ input: {
97
+ extensionId,
98
+ versionName: opts.versionName,
99
+ },
100
+ },
101
+ }),
102
+ });
103
+ if (!createResult.ok) {
104
+ const message = `Failed to create an extension version (status ${createResult.status})`;
105
+ try {
106
+ const data = await createResult.json();
107
+ console.error(`${message}:`, parseErrorMessage(data));
108
+ process.exit(1);
109
+ } catch {
110
+ console.error(message);
111
+ process.exit(1);
112
+ }
113
+ }
114
+ const data = await createResult.json();
115
+ const parseResult = createExtensionVersionResultSchema.safeParse(data);
116
+ if (!parseResult.success) {
117
+ console.error('Failed to create an extension version:', parseResult.error.message);
118
+ process.exit(1);
119
+ }
120
+ const result = parseResult.data;
121
+ if (result.errors) {
122
+ console.error('Failed to create an extension version:', parseErrorMessage(result));
123
+ process.exit(1);
124
+ }
125
+ if (result.data?.createExtensionVersion.__typename === 'CreateExtensionVersionSuccessResult') {
126
+ extensionVersionId = result.data.createExtensionVersion.extensionVersion.id;
127
+ signedUrl = result.data.createExtensionVersion.signedUrl;
128
+ } else {
129
+ console.error(
130
+ `Failed to create an extension version (${result.data?.createExtensionVersion.__typename ?? 'unknown error'}):`,
131
+ parseErrorMessage(result.data?.createExtensionVersion),
132
+ );
133
+ process.exit(1);
134
+ }
135
+ } catch (e) {
136
+ console.error('Failed to create an extension version:', parseErrorMessage(e));
137
+ process.exit(1);
138
+ }
139
+
140
+ const archive = archiver('zip', {
141
+ zlib: { level: 6 },
142
+ });
143
+
144
+ archive.on('error', (err) => {
145
+ console.error('Failed to archive:', parseErrorMessage(err));
146
+ process.exit(1);
147
+ });
148
+
149
+ archive.on('warning', (err) => {
150
+ console.warn('Archive warning:', err.message);
151
+ });
152
+
153
+ let processedBytes = 0;
154
+ let lastProcessedBytes = 0;
155
+
156
+ if (opts.verbose) {
157
+ archive.on('progress', (progress) => {
158
+ processedBytes += progress.fs.processedBytes;
159
+ if (processedBytes >= lastProcessedBytes + 1024 * 100) {
160
+ lastProcessedBytes = processedBytes;
161
+ console.log(`Processed ${bytesFormat.format(processedBytes)}`);
162
+ }
163
+ });
164
+ }
165
+
166
+ const cwd = process.cwd();
167
+ archive.glob('**/*', {
168
+ cwd,
169
+ ignore: [
170
+ '**/.git/**/*',
171
+ '.git/**/*',
172
+ 'eslint.config.js',
173
+ '**/eslint.config.js',
174
+ 'node_modules/**/*',
175
+ '**/node_modules/**/*',
176
+ ],
177
+ });
178
+
179
+ if (opts.verbose) {
180
+ console.log(`Uploading files at ${cwd}`);
181
+ }
182
+
183
+ // Must not await here because otherwise it doesn't work.
184
+ const finalizePromise = archive.finalize();
185
+ await finalizePromise;
186
+
187
+ const uploadResponse = await fetch(signedUrl, {
188
+ method: 'PUT',
189
+ headers: {
190
+ 'Content-Type': 'application/zip',
191
+ },
192
+ duplex: 'half',
193
+ body: archive,
194
+ });
195
+
196
+ if (uploadResponse.status !== 200) {
197
+ console.error('Failed to upload:', uploadResponse.statusText);
198
+ process.exit(1);
199
+ }
200
+
201
+ if (opts.verbose) {
202
+ console.log('Uploaded');
203
+ }
204
+
205
+ console.log(`Created version ${opts.versionName} (ID ${extensionVersionId})`);
206
+ };
@@ -0,0 +1,142 @@
1
+ import { hashPassword, validatePasswordComplexity } from '@wirechunk/backend-lib/passwords.ts';
2
+ import { normalizeEmailAddress } from '@wirechunk/lib/emails.ts';
3
+ import { createPool, sql, UniqueIntegrityConstraintViolationError } from 'slonik';
4
+ import { z } from 'zod';
5
+ import type { Env } from '../env.ts';
6
+ import { requireCoreDbUrl } from '../env.ts';
7
+ import { detailedUniqueIntegrityConstraintViolationError } from '../errors.ts';
8
+ import type { WithGlobalOptions } from '../global-options.ts';
9
+
10
+ const insertOrgResult = z.object({
11
+ id: z.string(),
12
+ });
13
+
14
+ const insertUserResult = z.object({
15
+ id: z.string(),
16
+ });
17
+
18
+ const findOrgResult = z.object({
19
+ platformId: z.string(),
20
+ });
21
+
22
+ const findPlatformResult = z.object({
23
+ id: z.string(),
24
+ name: z.string(),
25
+ });
26
+
27
+ type CreateUserOptions = {
28
+ email: string;
29
+ password: string;
30
+ firstName: string;
31
+ lastName: string;
32
+ orgId?: string;
33
+ platformId?: string;
34
+ role: string;
35
+ emailVerified: boolean;
36
+ pending: boolean;
37
+ };
38
+
39
+ export const createUser = async (
40
+ opts: WithGlobalOptions<CreateUserOptions>,
41
+ env: Env,
42
+ ): Promise<void> => {
43
+ const db = await createPool(requireCoreDbUrl(env));
44
+
45
+ const normalizeEmailResult = normalizeEmailAddress(opts.email);
46
+ if (!normalizeEmailResult.ok) {
47
+ console.error(normalizeEmailResult.error);
48
+ process.exit(1);
49
+ }
50
+ const email = normalizeEmailResult.value;
51
+ const validatePasswordResult = validatePasswordComplexity(opts.password);
52
+ if (!validatePasswordResult.ok) {
53
+ console.error(validatePasswordResult.error);
54
+ process.exit(1);
55
+ }
56
+ const password = await hashPassword(opts.password);
57
+ const { role } = opts;
58
+ const status = opts.pending ? 'Pending' : 'Active';
59
+ const firstName = opts.firstName.trim();
60
+ if (!firstName.length) {
61
+ console.error('First name must not be empty');
62
+ process.exit(1);
63
+ }
64
+ const lastName = opts.lastName.trim();
65
+ if (!lastName.length) {
66
+ console.error('Last name must not be empty');
67
+ process.exit(1);
68
+ }
69
+
70
+ let platformId: string | null | undefined = opts.platformId;
71
+ let orgId: string | null | undefined = opts.orgId;
72
+ let orgPrimary = false;
73
+
74
+ try {
75
+ const user = await db.transaction(async (db) => {
76
+ if (!platformId) {
77
+ if (!orgId) {
78
+ throw new Error('Either --org-id or --platform-id must be specified');
79
+ }
80
+ platformId = await db.maybeOneFirst(
81
+ sql.type(findOrgResult)`select "platformId" from "Organizations" where "id" = ${orgId}`,
82
+ );
83
+ if (!platformId) {
84
+ throw new Error(`No org found with ID ${orgId}`);
85
+ }
86
+ } else if (orgId) {
87
+ // Verify that the specified org belongs to the specified platform.
88
+ const orgPlatformId = await db.maybeOneFirst(
89
+ sql.type(findOrgResult)`select "platformId" from "Organizations" where "id" = ${orgId}`,
90
+ );
91
+ if (!orgPlatformId) {
92
+ throw new Error(`No org found with ID ${orgId}`);
93
+ }
94
+ if (orgPlatformId !== platformId) {
95
+ throw new Error(`Org ID ${orgId} does not belong to platform ID ${platformId}`);
96
+ }
97
+ }
98
+ const platform = await db.maybeOne(
99
+ sql.type(
100
+ findPlatformResult,
101
+ )`select "id", "name" from "Platforms" where "id" = ${platformId}`,
102
+ );
103
+ if (!platform) {
104
+ throw new Error(`No platform found with ID ${platformId}`);
105
+ }
106
+ if (opts.verbose) {
107
+ console.log(`Found platform ${platform.name} (ID ${platform.id})`);
108
+ }
109
+ if (!orgId) {
110
+ orgId = await db.oneFirst(
111
+ sql.type(
112
+ insertOrgResult,
113
+ )`insert into "Organizations" ("platformId") values (${platform.id}) returning "id"`,
114
+ );
115
+ if (opts.verbose) {
116
+ console.log(`Created org ID ${orgId}`);
117
+ }
118
+ orgPrimary = true;
119
+ }
120
+
121
+ const user = await db.one(
122
+ sql.type(
123
+ insertUserResult,
124
+ )`insert into "Users" ("platformId", "email", "emailVerified", "password", "passwordStatus", "orgId", "orgPrimary", "role", "status", "firstName", "lastName") values (${platformId}, ${email}, ${opts.emailVerified}, ${password}, 'Ok', ${orgId}, ${orgPrimary}, ${role}, ${status}, ${firstName}, ${lastName}) returning "id"`,
125
+ );
126
+
127
+ // if (opts.admin) {
128
+ // await grantAllUserPlatformPermissions({ userId: user.id, platformId }, db);
129
+ // }
130
+
131
+ return user;
132
+ });
133
+ console.log(`Created user (ID ${user.id})`);
134
+ } catch (e) {
135
+ if (e instanceof UniqueIntegrityConstraintViolationError) {
136
+ console.error(detailedUniqueIntegrityConstraintViolationError(e));
137
+ process.exit(1);
138
+ }
139
+ console.error(e);
140
+ process.exit(1);
141
+ }
142
+ };