@tabbr/module-cli 0.2.0 → 0.3.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/README.md +74 -1
- package/bin/tabbr-module.js +57 -2
- package/package.json +1 -1
- package/src/backend-build.js +63 -14
- package/src/build.js +3 -1
- package/src/publish.js +73 -1
- package/src/source.js +70 -0
package/README.md
CHANGED
|
@@ -85,9 +85,16 @@ export function pollFlights() {
|
|
|
85
85
|
only by a `publish --local` (e.g. `localhost` for a mock of the external service you develop against) - never by a normal publish.
|
|
86
86
|
- `tabbr.settings.get(key)` / `.set(key, value)` - this module's own per-club settings, including
|
|
87
87
|
its own secrets (never sent back to any settings GET response once saved - `secretFieldNames`).
|
|
88
|
+
- `tabbr.access` - the club's roles (`roles()`), and - with `"manageRoles": true` in the `backend` block - the roles
|
|
89
|
+
this module gives persons: `setManagedRoles(personId, roleCodes)` makes them exactly these (e.g. groups giving its
|
|
90
|
+
members a role), `managedRoles(personId)`, `managedAssignments()`. A module only ever touches the roles it gives
|
|
91
|
+
itself (roles assigned by hand stay), can't give a system role such as ADMIN, and the roles it gave go when it is
|
|
92
|
+
uninstalled or blocked. The club sees "gives roles" when it installs the module.
|
|
88
93
|
- `triggers[].type: "CRON"` runs on the given cron expression (server-local time), with no
|
|
89
94
|
triggering entity. `"ENTITY_HOOK"` runs on the named `entity`'s `event` (`onCreate`/`onUpdate`/
|
|
90
|
-
`onDelete`), with that entity bound under its own name (e.g. `flight`) in the handler's scope.
|
|
95
|
+
`onDelete`), with that entity bound under its own name (e.g. `flight`) in the handler's scope - before the save.
|
|
96
|
+
`onCreated` / `onUpdated` run after it, once committed, in the background: for a handler that reads the database
|
|
97
|
+
(e.g. "is this person in that list now"). A module's own writes don't trigger its handlers.
|
|
91
98
|
- Needs `esbuild` installed in the workspace (`npm install -D esbuild`) - only used at publish
|
|
92
99
|
time, bundled into one dependency-free `bundle.js` the server runs in a GraalVM sandbox, so
|
|
93
100
|
regular npm packages can be imported in your TS but nothing Node-specific (no `fs`, no native
|
|
@@ -96,6 +103,32 @@ export function pollFlights() {
|
|
|
96
103
|
A "resync now" button (or similar) can fire a `CRON` trigger manually:
|
|
97
104
|
`POST /api/custommodule/{moduleId}/trigger/{triggerName}` (ADMIN).
|
|
98
105
|
|
|
106
|
+
## Entities
|
|
107
|
+
|
|
108
|
+
A module can declare its own **entities** - records Tabbr stores and serves like its own (lists, filters, access
|
|
109
|
+
rules, the club's extra fields, hooks, audit). Add an `"entities"` array to `tabbr.config.json`:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"entities": [
|
|
114
|
+
{
|
|
115
|
+
"name": "group",
|
|
116
|
+
"label": "Groep",
|
|
117
|
+
"display": ["name"],
|
|
118
|
+
"fields": [
|
|
119
|
+
{ "key": "name", "label": "Naam", "type": "tabbr.text", "includeInSearch": true },
|
|
120
|
+
{ "key": "parent", "label": "Bovenliggende groep", "type": "tabbr.entity", "target": "group" }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`publish` checks the declarations (names, keys, types) before uploading and sends them in the backend manifest - also
|
|
128
|
+
for a module that has no backend code (then only the entities are published). The frontend addresses records by field
|
|
129
|
+
key with the SDK's `injectModuleEntity()`, the backend with `tabbr.entities.<name>`. See `docs/module-entities.md` in the
|
|
130
|
+
Tabbr repository for the field types, access rules and what the club gets; `tabbr-groups-module/` is a complete example.
|
|
131
|
+
|
|
99
132
|
## Usage: publish
|
|
100
133
|
|
|
101
134
|
```
|
|
@@ -135,6 +168,46 @@ doesn't do on its own (`NODE_EXTRA_CA_CERTS` is set automatically; if mkcert's r
|
|
|
135
168
|
set it yourself - `mkcert -CAROOT` shows where it lives). `--club` picks the club in that environment;
|
|
136
169
|
login still goes through the real Keycloak, which local backends validate too.
|
|
137
170
|
|
|
171
|
+
### Publishing to the module store
|
|
172
|
+
|
|
173
|
+
`npx tabbr-module publish --store [--changelog "..."]` submits a new **version of a store
|
|
174
|
+
package** instead of publishing it into one club (see `docs/module-store.md` in the Tabbr repo):
|
|
175
|
+
the workspace's **source** - the files git tracks (plus new ones git doesn't ignore), never
|
|
176
|
+
`node_modules` or build output - with its `package-lock.json`. The store builds it with the
|
|
177
|
+
platform's toolchain (`tabbr-module-builder`: its own `@tabbr/module-sdk` and `-cli`, your locked
|
|
178
|
+
dependencies, your `test` and `typecheck` scripts, no network during the build); then it goes into
|
|
179
|
+
review; once approved, every club can install it under *Instellingen › Modules › Store*. Unlink
|
|
180
|
+
yalc first (`npm run unlink-local`): the store installs from the lockfile. `--prebuilt` uploads a
|
|
181
|
+
local build instead (trusted publishers only). For now only platform admins (`SUPER_ADMIN`) can
|
|
182
|
+
submit.
|
|
183
|
+
|
|
184
|
+
`npx tabbr-module build --out module.zip` builds the module into a zip without publishing it -
|
|
185
|
+
what the store's builder runs.
|
|
186
|
+
|
|
187
|
+
It needs, in `tabbr.config.json`:
|
|
188
|
+
|
|
189
|
+
```jsonc
|
|
190
|
+
{
|
|
191
|
+
"moduleId": "groups",
|
|
192
|
+
"package": "tabbr/groups", // <publisher>/<name>
|
|
193
|
+
"version": "1.2.0", // higher than every earlier version of the package
|
|
194
|
+
"engines": { "tabbr": "^1.0" }, // the module API levels it runs on (MODULE_API_LEVEL in the SDK)
|
|
195
|
+
"dependencies": { }, // optional: { "acme/payments": "^1", "sys.messaging": "*" }
|
|
196
|
+
"optionalDependencies": { "sys.messaging": "*" },
|
|
197
|
+
"permissions": { // shown to the club before it installs or updates
|
|
198
|
+
"entities": { "read": ["person"], "write": [] },
|
|
199
|
+
"personalData": ["contact"],
|
|
200
|
+
"messaging": true
|
|
201
|
+
},
|
|
202
|
+
"summary": "One line for the store",
|
|
203
|
+
"visibility": "PUBLIC" // or UNLISTED
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The server refuses a version that can never work: a version not higher than the last, `engines`
|
|
208
|
+
that don't include the server's module API level, a changed `moduleId`, or - within the same major
|
|
209
|
+
version - entity declarations that remove or change something (a new major may).
|
|
210
|
+
|
|
138
211
|
## Usage: fetch-types
|
|
139
212
|
|
|
140
213
|
```
|
package/bin/tabbr-module.js
CHANGED
|
@@ -5,7 +5,8 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { getAccessToken } = require('../src/auth');
|
|
7
7
|
const { buildAndZip } = require('../src/build');
|
|
8
|
-
const { publish } = require('../src/publish');
|
|
8
|
+
const { publish, publishToStore, submitSourceToStore, storeMeta } = require('../src/publish');
|
|
9
|
+
const { packSource } = require('../src/source');
|
|
9
10
|
const { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS } = require('../src/fetch-types');
|
|
10
11
|
const { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit } = require('../src/config');
|
|
11
12
|
const { DEFAULT_LOCAL_API_HOST, trustLocalCertificateOrRespawn } = require('../src/local');
|
|
@@ -29,6 +30,9 @@ function parseArgs(argv) {
|
|
|
29
30
|
case '--entity': args.entity = next(); break;
|
|
30
31
|
case '--kind': args.kind = next(); break;
|
|
31
32
|
case '--out': args.out = next(); break;
|
|
33
|
+
case '--store': args.store = true; break;
|
|
34
|
+
case '--prebuilt': args.prebuilt = true; break;
|
|
35
|
+
case '--changelog': args.changelog = next(); break;
|
|
32
36
|
default:
|
|
33
37
|
throw new Error(`Unknown option: ${arg}`);
|
|
34
38
|
}
|
|
@@ -80,6 +84,24 @@ Optional:
|
|
|
80
84
|
Requires the tabbr Keycloak client's Valid Redirect URIs to include http://localhost:*
|
|
81
85
|
(a Keycloak admin-console setting - see README.md).
|
|
82
86
|
|
|
87
|
+
Usage: tabbr-module publish --store [--changelog <text>] [--prebuilt] [options]
|
|
88
|
+
|
|
89
|
+
Submits a new version of a package to the module store (docs/module-store.md) instead of
|
|
90
|
+
publishing it into one club: the workspace's SOURCE (git-tracked files, with package-lock.json).
|
|
91
|
+
The store builds it with the platform's toolchain, then it waits for review; once approved,
|
|
92
|
+
clubs install it from Instellingen > Modules. --prebuilt uploads a local build instead (only
|
|
93
|
+
for trusted publishers). No --club needed. Needs, in ${CONFIG_FILE}:
|
|
94
|
+
package "<publisher>/<name>", e.g. "tabbr/groups"
|
|
95
|
+
version "1.4.0" - higher than every earlier version of the package
|
|
96
|
+
engines { "tabbr": "^1.0" } - the module API levels it runs on
|
|
97
|
+
and optionally dependencies, optionalDependencies, permissions, summary, visibility (PUBLIC or
|
|
98
|
+
UNLISTED) - see the groups module's ${CONFIG_FILE} for an example.
|
|
99
|
+
|
|
100
|
+
Usage: tabbr-module build --out <module.zip> [--cwd <path>]
|
|
101
|
+
|
|
102
|
+
Builds the module (frontend and/or backend part, as publish does) into a zip without
|
|
103
|
+
publishing it - what the store's builder runs.
|
|
104
|
+
|
|
83
105
|
Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
|
|
84
106
|
|
|
85
107
|
With --entity: fetches the ambient .d.ts for that ONE entity's script sandbox - the same typings
|
|
@@ -113,6 +135,10 @@ function requireFlags(args, requiredFlags) {
|
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
async function runPublish(args) {
|
|
138
|
+
if (args.store) {
|
|
139
|
+
await runPublishToStore(args);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
116
142
|
args.club = requireClub(args.club);
|
|
117
143
|
requireFlags(args, {
|
|
118
144
|
moduleId: '--module-id',
|
|
@@ -139,6 +165,33 @@ async function runPublish(args) {
|
|
|
139
165
|
console.log(JSON.stringify(result, null, 2));
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
async function runPublishToStore(args) {
|
|
169
|
+
requireFlags(args, { moduleId: '--module-id' });
|
|
170
|
+
const meta = storeMeta(args);
|
|
171
|
+
let version;
|
|
172
|
+
if (args.prebuilt) {
|
|
173
|
+
const zipBuffer = await buildAndZip(args.cwd, args);
|
|
174
|
+
const accessToken = await getAccessToken();
|
|
175
|
+
version = await publishToStore({ apiHost: args.apiHost, packageName: args.package, meta, zipBuffer, accessToken });
|
|
176
|
+
} else {
|
|
177
|
+
const { zip, files } = await packSource(args.cwd);
|
|
178
|
+
console.log(`Source: ${files.length} files, ${Math.round(zip.length / 1024)} KB`);
|
|
179
|
+
const accessToken = await getAccessToken();
|
|
180
|
+
version = await submitSourceToStore({ apiHost: args.apiHost, packageName: args.package, meta, sourceZip: zip, accessToken });
|
|
181
|
+
}
|
|
182
|
+
console.log(`Submitted ${version.packageName} ${version.version} - ${version.state}.` +
|
|
183
|
+
(version.state === 'BUILDING' ? ' The store builds it; then it is reviewed, and installable once approved.' : ' It can be installed once it is approved.'));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runBuild(args) {
|
|
187
|
+
requireFlags(args, { out: '--out' });
|
|
188
|
+
const zipBuffer = await buildAndZip(args.cwd, args);
|
|
189
|
+
const out = path.resolve(args.cwd, args.out);
|
|
190
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
191
|
+
fs.writeFileSync(out, zipBuffer);
|
|
192
|
+
console.log(`Wrote ${out} (${Math.round(zipBuffer.length / 1024)} KB)`);
|
|
193
|
+
}
|
|
194
|
+
|
|
142
195
|
async function fetchAndWriteOne({ apiHost, club, entity, kind, cwd, out, accessToken }) {
|
|
143
196
|
const dts = await fetchScriptTypes({ apiHost, club, entity, kind, accessToken });
|
|
144
197
|
|
|
@@ -219,7 +272,7 @@ async function runFetchTypes(args) {
|
|
|
219
272
|
async function main() {
|
|
220
273
|
const [command, ...rest] = process.argv.slice(2);
|
|
221
274
|
|
|
222
|
-
if (
|
|
275
|
+
if (!['init', 'publish', 'build', 'fetch-types'].includes(command)) {
|
|
223
276
|
printUsage();
|
|
224
277
|
process.exit(command ? 1 : 0);
|
|
225
278
|
}
|
|
@@ -238,6 +291,8 @@ async function main() {
|
|
|
238
291
|
trustLocalCertificateOrRespawn(args.apiHost);
|
|
239
292
|
if (command === 'publish') {
|
|
240
293
|
await runPublish(args);
|
|
294
|
+
} else if (command === 'build') {
|
|
295
|
+
await runBuild(args);
|
|
241
296
|
} else {
|
|
242
297
|
await runFetchTypes(args);
|
|
243
298
|
}
|
package/package.json
CHANGED
package/src/backend-build.js
CHANGED
|
@@ -9,6 +9,43 @@ const DEFAULT_ENTRY = 'backend/src/index.ts';
|
|
|
9
9
|
* in the module's TS ends up reachable as the global `__module.foo` once bundled this way. */
|
|
10
10
|
const GLOBAL_NAME = '__module';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The entities the module declares (`"entities"` in tabbr.config.json), checked just enough to fail here rather than at
|
|
14
|
+
* upload with a vaguer message: Tabbr validates them again against the club (names taken, entity fields pointing at
|
|
15
|
+
* entities the club has, ...). See `ModuleEntityDefinition` in tabbr-api-server for the full shape.
|
|
16
|
+
*/
|
|
17
|
+
function entitiesOf(config) {
|
|
18
|
+
const entities = config.entities || [];
|
|
19
|
+
if (!Array.isArray(entities)) {
|
|
20
|
+
throw new Error('"entities" in tabbr.config.json must be an array.');
|
|
21
|
+
}
|
|
22
|
+
for (const entity of entities) {
|
|
23
|
+
if (!entity || !/^[a-z][a-zA-Z0-9]{1,39}$/.test(entity.name || '')) {
|
|
24
|
+
throw new Error(`Entity name "${entity && entity.name}" is not valid: camelCase letters and digits, starting lowercase.`);
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(entity.fields) || entity.fields.length === 0) {
|
|
27
|
+
throw new Error(`Entity "${entity.name}" declares no fields.`);
|
|
28
|
+
}
|
|
29
|
+
const keys = new Set();
|
|
30
|
+
for (const field of entity.fields) {
|
|
31
|
+
if (!/^[a-z][a-zA-Z0-9]{0,39}$/.test(field.key || '')) {
|
|
32
|
+
throw new Error(`Entity "${entity.name}": field key "${field.key}" is not valid.`);
|
|
33
|
+
}
|
|
34
|
+
if (keys.has(field.key)) {
|
|
35
|
+
throw new Error(`Entity "${entity.name}": field "${field.key}" is declared twice.`);
|
|
36
|
+
}
|
|
37
|
+
keys.add(field.key);
|
|
38
|
+
if (!field.type && !field.persistentType) {
|
|
39
|
+
throw new Error(`Entity "${entity.name}": field "${field.key}" has no type (e.g. "tabbr.text").`);
|
|
40
|
+
}
|
|
41
|
+
if (field.type === 'tabbr.entity' && !field.target) {
|
|
42
|
+
throw new Error(`Entity "${entity.name}": entity field "${field.key}" has no "target".`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return entities;
|
|
47
|
+
}
|
|
48
|
+
|
|
12
49
|
/**
|
|
13
50
|
* Bundles a module workspace's backend part (if configured) into a single, dependency-free
|
|
14
51
|
* `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
|
|
@@ -16,14 +53,35 @@ const GLOBAL_NAME = '__module';
|
|
|
16
53
|
* server evaluates it in a sandboxed GraalVM context with no module resolution of its own (see
|
|
17
54
|
* `BackendModuleRuntimeService`).
|
|
18
55
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
56
|
+
* Also writes the manifest the server reads (`manifest.json`): the backend config plus the entities the module declares
|
|
57
|
+
* - which a module can do without any backend code, so then only the manifest is produced (`bundleJs` null).
|
|
58
|
+
*
|
|
59
|
+
* Returns `null` when the workspace has neither a backend part nor entities, so `build.js` can skip
|
|
60
|
+
* it entirely for a frontend-only module.
|
|
21
61
|
*/
|
|
22
62
|
async function buildBackend(cwd, config) {
|
|
23
63
|
const backendConfig = config.backend;
|
|
24
|
-
|
|
64
|
+
const entities = entitiesOf(config);
|
|
65
|
+
const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
|
|
66
|
+
if (bundleJs == null && entities.length === 0) {
|
|
25
67
|
return null;
|
|
26
68
|
}
|
|
69
|
+
const settings = backendConfig || {};
|
|
70
|
+
const manifest = {
|
|
71
|
+
// localAllowedHosts (e.g. a mock on localhost) only ever go into a --local publish, never into one for a real environment.
|
|
72
|
+
allowedHosts: [...(settings.allowedHosts || []), ...(config.local ? settings.localAllowedHosts || [] : [])],
|
|
73
|
+
secretFieldNames: settings.secretFieldNames || [],
|
|
74
|
+
triggers: bundleJs == null ? [] : settings.triggers || [],
|
|
75
|
+
entities,
|
|
76
|
+
// the module gives persons roles (tabbr.access.setManagedRoles) - shown to the club when it installs the module
|
|
77
|
+
manageRoles: bundleJs != null && settings.manageRoles === true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
|
|
84
|
+
async function bundle(cwd, backendConfig) {
|
|
27
85
|
const entry = backendConfig.entry || DEFAULT_ENTRY;
|
|
28
86
|
const entryPath = path.join(cwd, entry);
|
|
29
87
|
if (!fs.existsSync(entryPath)) {
|
|
@@ -59,16 +117,7 @@ async function buildBackend(cwd, config) {
|
|
|
59
117
|
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
60
118
|
throw new Error(`esbuild produced no output for ${entryPath}`);
|
|
61
119
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const manifest = {
|
|
65
|
-
// localAllowedHosts (e.g. a mock on localhost) only ever go into a --local publish, never into one for a real environment.
|
|
66
|
-
allowedHosts: [...(backendConfig.allowedHosts || []), ...(config.local ? backendConfig.localAllowedHosts || [] : [])],
|
|
67
|
-
secretFieldNames: backendConfig.secretFieldNames || [],
|
|
68
|
-
triggers: backendConfig.triggers || [],
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
120
|
+
return result.outputFiles[0].text;
|
|
72
121
|
}
|
|
73
122
|
|
|
74
|
-
module.exports = { buildBackend, DEFAULT_ENTRY };
|
|
123
|
+
module.exports = { buildBackend, entitiesOf, DEFAULT_ENTRY };
|
package/src/build.js
CHANGED
|
@@ -78,7 +78,9 @@ function zipModule(frontendDir, backend) {
|
|
|
78
78
|
archive.directory(frontendDir, false);
|
|
79
79
|
}
|
|
80
80
|
if (backend) {
|
|
81
|
-
|
|
81
|
+
if (backend.bundleJs != null) {
|
|
82
|
+
archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
|
|
83
|
+
}
|
|
82
84
|
archive.append(backend.manifestJson, { name: 'backend/manifest.json' });
|
|
83
85
|
}
|
|
84
86
|
archive.finalize();
|
package/src/publish.js
CHANGED
|
@@ -28,4 +28,76 @@ async function publish({ apiHost, club, moduleId, exposedModule, exportName, ico
|
|
|
28
28
|
return response.json();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Submits the zipped build as a new version of a store package (docs/module-store.md): POST
|
|
33
|
+
* /api/store/packages/<publisher>/<name>/versions with the build and the version's metadata. The
|
|
34
|
+
* version then waits for review; clubs install it from the store once it is approved.
|
|
35
|
+
*/
|
|
36
|
+
async function publishToStore({ apiHost, packageName, meta, zipBuffer, accessToken }) {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
form.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'module.zip');
|
|
39
|
+
form.append('meta', new Blob([JSON.stringify(meta)], { type: 'application/json' }));
|
|
40
|
+
|
|
41
|
+
const url = `${apiHost}/api/store/packages/${packageName}/versions`;
|
|
42
|
+
console.log(`Submitting ${packageName} ${meta.version} to the module store at ${url} ...`);
|
|
43
|
+
const response = await fetch(url, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
46
|
+
body: form,
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Submit failed: ${response.status} ${await response.text()}`);
|
|
50
|
+
}
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The version's metadata for the store, from tabbr.config.json (and the command line). */
|
|
55
|
+
function storeMeta(args) {
|
|
56
|
+
const missing = [];
|
|
57
|
+
if (!args.package) missing.push('"package" (e.g. "acme/groups")');
|
|
58
|
+
if (!args.version) missing.push('"version" (e.g. "1.0.0")');
|
|
59
|
+
if (!args.engines || !args.engines.tabbr) missing.push('"engines": { "tabbr": "^1.0" } (the module API levels it runs on)');
|
|
60
|
+
if (missing.length) {
|
|
61
|
+
throw new Error(`Missing in tabbr.config.json for --store: ${missing.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
version: args.version,
|
|
65
|
+
moduleId: args.moduleId,
|
|
66
|
+
exposedModule: args.exposedModule,
|
|
67
|
+
exportName: args.exportName,
|
|
68
|
+
title: args.title,
|
|
69
|
+
icon: args.icon,
|
|
70
|
+
summary: args.summary,
|
|
71
|
+
changelog: args.changelog,
|
|
72
|
+
visibility: args.visibility,
|
|
73
|
+
engines: args.engines,
|
|
74
|
+
dependencies: args.dependencies || {},
|
|
75
|
+
optionalDependencies: args.optionalDependencies || {},
|
|
76
|
+
permissions: args.permissions || {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Submits the workspace's source as a new version of a store package: POST
|
|
82
|
+
* /api/store/packages/<publisher>/<name>/sources. The store builds it (tabbr-module-builder) and
|
|
83
|
+
* only then puts it in review.
|
|
84
|
+
*/
|
|
85
|
+
async function submitSourceToStore({ apiHost, packageName, meta, sourceZip, accessToken }) {
|
|
86
|
+
const form = new FormData();
|
|
87
|
+
form.append('source', new Blob([sourceZip], { type: 'application/zip' }), 'source.zip');
|
|
88
|
+
form.append('meta', new Blob([JSON.stringify(meta)], { type: 'application/json' }));
|
|
89
|
+
|
|
90
|
+
const url = `${apiHost}/api/store/packages/${packageName}/sources`;
|
|
91
|
+
console.log(`Submitting the source of ${packageName} ${meta.version} to the module store at ${url} ...`);
|
|
92
|
+
const response = await fetch(url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
95
|
+
body: form,
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Submit failed: ${response.status} ${await response.text()}`);
|
|
99
|
+
}
|
|
100
|
+
return response.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { publish, publishToStore, submitSourceToStore, storeMeta };
|
package/src/source.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const archiver = require('archiver');
|
|
7
|
+
|
|
8
|
+
/** Never part of a module's source: installed packages, build output, caches, editor state. */
|
|
9
|
+
const SKIP = new Set(['node_modules', 'dist', '.angular', 'out-tsc', '.git', '.yalc', '.idea', '.vscode', 'coverage']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The workspace's source files, as the module store builds them: the files git tracks (plus new ones git doesn't ignore)
|
|
13
|
+
* when the workspace is in a git repository, otherwise every file outside the SKIP folders. Paths relative to `cwd`.
|
|
14
|
+
*/
|
|
15
|
+
function sourceFiles(cwd) {
|
|
16
|
+
let listed = null;
|
|
17
|
+
try {
|
|
18
|
+
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'],
|
|
19
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
20
|
+
listed = out.split('\0').filter(Boolean);
|
|
21
|
+
} catch {
|
|
22
|
+
// not a git repository (or no git): walk the folder instead
|
|
23
|
+
}
|
|
24
|
+
const files = listed ?? walk(cwd, '');
|
|
25
|
+
return files
|
|
26
|
+
.filter(file => !file.split('/').some(part => SKIP.has(part)))
|
|
27
|
+
.filter(file => fs.existsSync(path.join(cwd, file)) && fs.statSync(path.join(cwd, file)).isFile())
|
|
28
|
+
.sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(root, rel) {
|
|
32
|
+
const result = [];
|
|
33
|
+
for (const entry of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
34
|
+
if (SKIP.has(entry.name)) continue;
|
|
35
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
36
|
+
if (entry.isDirectory()) result.push(...walk(root, child));
|
|
37
|
+
else if (entry.isFile()) result.push(child);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A zip of the workspace's source (see sourceFiles), checked to have what the store needs to build it. */
|
|
43
|
+
function packSource(cwd) {
|
|
44
|
+
const files = sourceFiles(cwd);
|
|
45
|
+
for (const required of ['package.json', 'package-lock.json']) {
|
|
46
|
+
if (!files.includes(required)) {
|
|
47
|
+
throw new Error(`${required} is missing in ${cwd}` +
|
|
48
|
+
(required === 'package-lock.json' ? ' - the store installs exactly the locked dependencies; run "npm install" and commit it' : ''));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// yalc-linked packages exist only on this machine: the store uses the platform's own SDK and CLI instead
|
|
52
|
+
const lock = fs.readFileSync(path.join(cwd, 'package-lock.json'), 'utf8');
|
|
53
|
+
if (lock.includes('file:.yalc/')) {
|
|
54
|
+
throw new Error('package-lock.json links @tabbr packages with yalc ("npm run link-local") - run "npm run unlink-local" ' +
|
|
55
|
+
'first: the store builds against the published packages and replaces @tabbr/module-sdk and -cli with the platform\'s own.');
|
|
56
|
+
}
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
59
|
+
const chunks = [];
|
|
60
|
+
archive.on('data', chunk => chunks.push(chunk));
|
|
61
|
+
archive.on('end', () => resolve({ zip: Buffer.concat(chunks), files }));
|
|
62
|
+
archive.on('error', reject);
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
archive.file(path.join(cwd, file), { name: file });
|
|
65
|
+
}
|
|
66
|
+
archive.finalize();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { packSource, sourceFiles };
|