@tabbr/module-cli 0.1.0 → 0.2.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 +34 -34
- package/README.md +193 -113
- package/bin/tabbr-module.js +40 -5
- package/package.json +34 -33
- package/src/auth.js +144 -144
- package/src/backend-build.js +74 -0
- package/src/build.js +121 -91
- package/src/config.js +75 -75
- package/src/fetch-types.js +49 -49
- package/src/local.js +62 -0
- package/src/publish.js +31 -29
package/src/config.js
CHANGED
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const readline = require('readline/promises');
|
|
6
|
-
|
|
7
|
-
const CONFIG_FILE = 'tabbr.config.json';
|
|
8
|
-
const DEFAULT_API_HOST = 'https://api.tabbr.be';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Reads the workspace's tabbr.config.json - the one place a module project records which club it
|
|
12
|
-
* targets, its module id and so on. Both this CLI and the module's own dev-preview harness read it,
|
|
13
|
-
* so they can't drift apart. Returns {} when there is no such file.
|
|
14
|
-
*/
|
|
15
|
-
function readConfig(cwd) {
|
|
16
|
-
const file = path.join(cwd, CONFIG_FILE);
|
|
17
|
-
if (!fs.existsSync(file)) return {};
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
20
|
-
} catch (err) {
|
|
21
|
-
throw new Error(`${file} is not valid JSON: ${err.message}`);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Uppercase club code, or throws with instructions when none is configured. */
|
|
26
|
-
function requireClub(club) {
|
|
27
|
-
if (!club) {
|
|
28
|
-
throw new Error(`No club configured. Run "npx tabbr-module init" (or set "club" in ${CONFIG_FILE}).`);
|
|
29
|
-
}
|
|
30
|
-
return club.toUpperCase();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function slug(text) {
|
|
34
|
-
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Asks for the club code (and offers defaults for the rest) and writes tabbr.config.json, keeping
|
|
39
|
-
* any values already in it as the defaults.
|
|
40
|
-
*/
|
|
41
|
-
async function runInit(cwd, overrides) {
|
|
42
|
-
const file = path.join(cwd, CONFIG_FILE);
|
|
43
|
-
const current = readConfig(cwd);
|
|
44
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
-
const ask = async (question, fallback) => {
|
|
46
|
-
if (!process.stdin.isTTY) return fallback;
|
|
47
|
-
const answer = (await rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `)).trim();
|
|
48
|
-
return answer || fallback;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const club = (overrides.club || await ask('Club code (the part before .tabbr.be, e.g. abc)', current.club))?.toUpperCase();
|
|
53
|
-
if (!club) throw new Error('A club code is required.');
|
|
54
|
-
const name = slug(path.basename(cwd)) || 'my-module';
|
|
55
|
-
const moduleId = overrides.moduleId
|
|
56
|
-
|| await ask('Module id (unique within the club)', current.moduleId || `club.${club.toLowerCase()}.${name}`);
|
|
57
|
-
const title = overrides.title || await ask('
|
|
58
|
-
|
|
59
|
-
const config = {
|
|
60
|
-
...current,
|
|
61
|
-
club,
|
|
62
|
-
apiHost: overrides.apiHost || current.apiHost || DEFAULT_API_HOST,
|
|
63
|
-
moduleId,
|
|
64
|
-
title,
|
|
65
|
-
exposedModule: current.exposedModule || './Module',
|
|
66
|
-
exportName: current.exportName || 'Module',
|
|
67
|
-
};
|
|
68
|
-
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
69
|
-
console.log(`\nWrote ${file}. Next: "npm start" to preview, "npm run publish-module" to publish.`);
|
|
70
|
-
} finally {
|
|
71
|
-
rl.close();
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
module.exports = { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline/promises');
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = 'tabbr.config.json';
|
|
8
|
+
const DEFAULT_API_HOST = 'https://api.tabbr.be';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the workspace's tabbr.config.json - the one place a module project records which club it
|
|
12
|
+
* targets, its module id and so on. Both this CLI and the module's own dev-preview harness read it,
|
|
13
|
+
* so they can't drift apart. Returns {} when there is no such file.
|
|
14
|
+
*/
|
|
15
|
+
function readConfig(cwd) {
|
|
16
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
17
|
+
if (!fs.existsSync(file)) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new Error(`${file} is not valid JSON: ${err.message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Uppercase club code, or throws with instructions when none is configured. */
|
|
26
|
+
function requireClub(club) {
|
|
27
|
+
if (!club) {
|
|
28
|
+
throw new Error(`No club configured. Run "npx tabbr-module init" (or set "club" in ${CONFIG_FILE}).`);
|
|
29
|
+
}
|
|
30
|
+
return club.toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slug(text) {
|
|
34
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Asks for the club code (and offers defaults for the rest) and writes tabbr.config.json, keeping
|
|
39
|
+
* any values already in it as the defaults.
|
|
40
|
+
*/
|
|
41
|
+
async function runInit(cwd, overrides) {
|
|
42
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
43
|
+
const current = readConfig(cwd);
|
|
44
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
const ask = async (question, fallback) => {
|
|
46
|
+
if (!process.stdin.isTTY) return fallback;
|
|
47
|
+
const answer = (await rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `)).trim();
|
|
48
|
+
return answer || fallback;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const club = (overrides.club || await ask('Club code (the part before .tabbr.be, e.g. abc)', current.club))?.toUpperCase();
|
|
53
|
+
if (!club) throw new Error('A club code is required.');
|
|
54
|
+
const name = slug(path.basename(cwd)) || 'my-module';
|
|
55
|
+
const moduleId = overrides.moduleId
|
|
56
|
+
|| await ask('Module id (unique within the club)', current.moduleId || `club.${club.toLowerCase()}.${name}`);
|
|
57
|
+
const title = overrides.title || await ask('Fallback title (the translated one is module.title in your i18n bundle)', current.title || 'My module');
|
|
58
|
+
|
|
59
|
+
const config = {
|
|
60
|
+
...current,
|
|
61
|
+
club,
|
|
62
|
+
apiHost: overrides.apiHost || current.apiHost || DEFAULT_API_HOST,
|
|
63
|
+
moduleId,
|
|
64
|
+
title,
|
|
65
|
+
exposedModule: current.exposedModule || './Module',
|
|
66
|
+
exportName: current.exportName || 'Module',
|
|
67
|
+
};
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
69
|
+
console.log(`\nWrote ${file}. Next: "npm start" to preview, "npm run publish-module" to publish.`);
|
|
70
|
+
} finally {
|
|
71
|
+
rl.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit };
|
package/src/fetch-types.js
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Mirrors ScriptTypeGenerator.ScriptKind (tabbr-api-server) - keep in sync if that enum changes.
|
|
4
|
-
const VALID_KINDS = ['HOOK', 'CALCULATION', 'DEFAULT_CALCULATION', 'RELEVANT_CONDITION'];
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Fetches the ambient .d.ts tabbr-ui's own script editor (Monaco, for club-configured hook/
|
|
8
|
-
* calculation/default-value/relevancy-condition scripts) feeds itself, for a given club/entity/
|
|
9
|
-
* kind - see ScriptTypeGenerator (tabbr-api-server) and ScriptEditorMonacoService (tabbr-ui).
|
|
10
|
-
*/
|
|
11
|
-
async function fetchScriptTypes({ apiHost, club, entity, kind, accessToken }) {
|
|
12
|
-
if (!VALID_KINDS.includes(kind)) {
|
|
13
|
-
throw new Error(`Invalid --kind "${kind}". Must be one of: ${VALID_KINDS.join(', ')}`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const url = `${apiHost}/api/${entity}/meta/script?kind=${kind}`;
|
|
17
|
-
console.log(`Fetching script types for entity "${entity}" (kind ${kind}) from ${url} ...`);
|
|
18
|
-
const response = await fetch(url, {
|
|
19
|
-
// X-club is required: CurrentClubFilter (tabbr-api-server) resolves the "current club" for
|
|
20
|
-
// this request from this header, not from any path variable - same pattern as publish.js.
|
|
21
|
-
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
if (!response.ok) {
|
|
25
|
-
throw new Error(`Fetching script types failed: ${response.status} ${await response.text()}`);
|
|
26
|
-
}
|
|
27
|
-
return response.text();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Every entity this club has, as plain TypeScript data interfaces in ONE combined file - no
|
|
32
|
-
* script-sandbox globals, so (unlike fetchScriptTypes) safe to fetch for every entity at once and
|
|
33
|
-
* write to a single .d.ts. See ScriptTypeGenerator#generateAllEntityTypes (tabbr-api-server) for
|
|
34
|
-
* why. For general club-data typing in module component code, not the script editor.
|
|
35
|
-
*/
|
|
36
|
-
async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
37
|
-
const url = `${apiHost}/api/club/${club.toUpperCase()}/entities/types`;
|
|
38
|
-
console.log(`Fetching all entity types for club ${club.toUpperCase()} from ${url} ...`);
|
|
39
|
-
const response = await fetch(url, {
|
|
40
|
-
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
if (!response.ok) {
|
|
44
|
-
throw new Error(`Fetching entity types failed: ${response.status} ${await response.text()}`);
|
|
45
|
-
}
|
|
46
|
-
return response.text();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Mirrors ScriptTypeGenerator.ScriptKind (tabbr-api-server) - keep in sync if that enum changes.
|
|
4
|
+
const VALID_KINDS = ['HOOK', 'CALCULATION', 'DEFAULT_CALCULATION', 'RELEVANT_CONDITION'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetches the ambient .d.ts tabbr-ui's own script editor (Monaco, for club-configured hook/
|
|
8
|
+
* calculation/default-value/relevancy-condition scripts) feeds itself, for a given club/entity/
|
|
9
|
+
* kind - see ScriptTypeGenerator (tabbr-api-server) and ScriptEditorMonacoService (tabbr-ui).
|
|
10
|
+
*/
|
|
11
|
+
async function fetchScriptTypes({ apiHost, club, entity, kind, accessToken }) {
|
|
12
|
+
if (!VALID_KINDS.includes(kind)) {
|
|
13
|
+
throw new Error(`Invalid --kind "${kind}". Must be one of: ${VALID_KINDS.join(', ')}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const url = `${apiHost}/api/${entity}/meta/script?kind=${kind}`;
|
|
17
|
+
console.log(`Fetching script types for entity "${entity}" (kind ${kind}) from ${url} ...`);
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
// X-club is required: CurrentClubFilter (tabbr-api-server) resolves the "current club" for
|
|
20
|
+
// this request from this header, not from any path variable - same pattern as publish.js.
|
|
21
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
throw new Error(`Fetching script types failed: ${response.status} ${await response.text()}`);
|
|
26
|
+
}
|
|
27
|
+
return response.text();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every entity this club has, as plain TypeScript data interfaces in ONE combined file - no
|
|
32
|
+
* script-sandbox globals, so (unlike fetchScriptTypes) safe to fetch for every entity at once and
|
|
33
|
+
* write to a single .d.ts. See ScriptTypeGenerator#generateAllEntityTypes (tabbr-api-server) for
|
|
34
|
+
* why. For general club-data typing in module component code, not the script editor.
|
|
35
|
+
*/
|
|
36
|
+
async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
37
|
+
const url = `${apiHost}/api/club/${club.toUpperCase()}/entities/types`;
|
|
38
|
+
console.log(`Fetching all entity types for club ${club.toUpperCase()} from ${url} ...`);
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Fetching entity types failed: ${response.status} ${await response.text()}`);
|
|
45
|
+
}
|
|
46
|
+
return response.text();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS };
|
package/src/local.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
/** The developer's own backend, behind the local reverse proxy (see tabbr-ui's api-host.ts). */
|
|
9
|
+
const DEFAULT_LOCAL_API_HOST = 'https://api.local.tabbr.be';
|
|
10
|
+
|
|
11
|
+
const LOCAL_SUFFIX = '.local.tabbr.be';
|
|
12
|
+
const RESPAWNED_MARKER = 'TABBR_CLI_LOCAL_TRUST_APPLIED';
|
|
13
|
+
|
|
14
|
+
function isLocalApiHost(apiHost) {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(apiHost).hostname.endsWith(LOCAL_SUFFIX);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Where `mkcert -install` keeps its root CA, which signs the *.local.tabbr.be certificate. */
|
|
23
|
+
function mkcertRootCa() {
|
|
24
|
+
const candidates = [];
|
|
25
|
+
if (process.env.CAROOT) candidates.push(path.join(process.env.CAROOT, 'rootCA.pem'));
|
|
26
|
+
if (process.platform === 'win32') {
|
|
27
|
+
candidates.push(path.join(process.env.LOCALAPPDATA || '', 'mkcert', 'rootCA.pem'));
|
|
28
|
+
} else if (process.platform === 'darwin') {
|
|
29
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'mkcert', 'rootCA.pem'));
|
|
30
|
+
} else {
|
|
31
|
+
candidates.push(path.join(os.homedir(), '.local', 'share', 'mkcert', 'rootCA.pem'));
|
|
32
|
+
}
|
|
33
|
+
return candidates.find((candidate) => fs.existsSync(candidate));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Node ignores the OS trust store by default, so a local dev environment signed by mkcert's private
|
|
38
|
+
* root CA fails with a certificate error even though the browser trusts it. NODE_EXTRA_CA_CERTS is
|
|
39
|
+
* only read at process start, so when the target is a *.local.tabbr.be host this re-runs the CLI
|
|
40
|
+
* once as a child with it set (and exits with the child's status). No-op for any other host, when
|
|
41
|
+
* the variable is already set, or when mkcert's root CA can't be found.
|
|
42
|
+
*/
|
|
43
|
+
function trustLocalCertificateOrRespawn(apiHost) {
|
|
44
|
+
if (!isLocalApiHost(apiHost) || process.env.NODE_EXTRA_CA_CERTS || process.env[RESPAWNED_MARKER]) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const rootCa = mkcertRootCa();
|
|
48
|
+
if (!rootCa) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`Publishing to ${apiHost} needs your mkcert root CA to be trusted, but it was not found. If the upload fails ` +
|
|
51
|
+
'with a certificate error, set NODE_EXTRA_CA_CERTS to the path of mkcert\'s rootCA.pem (see "mkcert -CAROOT").'
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const result = spawnSync(process.execPath, process.argv.slice(1), {
|
|
56
|
+
stdio: 'inherit',
|
|
57
|
+
env: { ...process.env, NODE_EXTRA_CA_CERTS: rootCa, [RESPAWNED_MARKER]: '1' },
|
|
58
|
+
});
|
|
59
|
+
process.exit(result.status === null ? 1 : result.status);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { DEFAULT_LOCAL_API_HOST, isLocalApiHost, trustLocalCertificateOrRespawn };
|
package/src/publish.js
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/** Uploads the zipped module build to the tabbr backend and returns the parsed JSON response. */
|
|
4
|
-
async function publish({ apiHost, club, moduleId, exposedModule, exportName, icon, title, zipBuffer, accessToken }) {
|
|
5
|
-
const form = new FormData();
|
|
6
|
-
form.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'module.zip');
|
|
7
|
-
form.append('moduleId', moduleId);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Uploads the zipped module build to the tabbr backend and returns the parsed JSON response. */
|
|
4
|
+
async function publish({ apiHost, club, moduleId, exposedModule, exportName, icon, title, zipBuffer, accessToken }) {
|
|
5
|
+
const form = new FormData();
|
|
6
|
+
form.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'module.zip');
|
|
7
|
+
form.append('moduleId', moduleId);
|
|
8
|
+
// Only present for a module with a frontend part - a backend-only module has neither, and the
|
|
9
|
+
// server's /api/custommodule accepts both as optional for exactly that reason.
|
|
10
|
+
if (exposedModule) form.append('exposedModule', exposedModule);
|
|
11
|
+
if (exportName) form.append('exportName', exportName);
|
|
12
|
+
if (icon) form.append('icon', icon);
|
|
13
|
+
if (title) form.append('title', title);
|
|
14
|
+
|
|
15
|
+
const url = `${apiHost}/api/custommodule`;
|
|
16
|
+
console.log(`Publishing ${moduleId} to club ${club.toUpperCase()} at ${url} ...`);
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
// X-club selects the club: the server publishes into the club CurrentClubFilter
|
|
20
|
+
// (tabbr-api-server) resolves from this header, the same club its ADMIN check runs against.
|
|
21
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'X-club': club.toUpperCase() },
|
|
22
|
+
body: form,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`Publish failed: ${response.status} ${await response.text()}`);
|
|
27
|
+
}
|
|
28
|
+
return response.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { publish };
|