@plitzi/cli 0.37.2 → 0.37.4
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/CHANGELOG.md +1168 -0
- package/README.md +157 -20
- package/dist/index.js +3582 -300
- package/package.json +14 -6
- package/skills/plitzi-cli/SKILL.md +154 -0
package/dist/index.js
CHANGED
|
@@ -1,13 +1,648 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Option, program } from "commander";
|
|
3
|
-
import
|
|
3
|
+
import chalk from "chalk";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
5
6
|
import path, { dirname, join, relative, sep } from "node:path";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import tls from "node:tls";
|
|
6
11
|
import readline from "node:readline/promises";
|
|
7
|
-
import chalk from "chalk";
|
|
8
12
|
import { createRequire } from "node:module";
|
|
9
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
10
|
-
import { blankSpaceSource } from "@plitzi/sdk-authoring";
|
|
13
|
+
import { readFileSync, readdirSync, watch } from "node:fs";
|
|
14
|
+
import { blankSpaceSource, elementTypeNames } from "@plitzi/sdk-authoring";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { build } from "esbuild";
|
|
17
|
+
import { unzipSync, zipSync } from "fflate";
|
|
18
|
+
/** `$XDG_CONFIG_HOME/plitzi`, `%APPDATA%\plitzi` on Windows, `~/.config/plitzi` otherwise. */
|
|
19
|
+
var configDir = () => {
|
|
20
|
+
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, "plitzi");
|
|
21
|
+
if (process.platform === "win32" && process.env.APPDATA) return path.join(process.env.APPDATA, "plitzi");
|
|
22
|
+
return path.join(os.homedir(), ".config", "plitzi");
|
|
23
|
+
};
|
|
24
|
+
var connectionFile = () => path.join(configDir(), "connection.json");
|
|
25
|
+
var isConnection = (value) => {
|
|
26
|
+
const candidate = value;
|
|
27
|
+
return typeof candidate?.api === "string" && typeof candidate.grant?.clientId === "string" && typeof candidate.grant.accessToken === "string";
|
|
28
|
+
};
|
|
29
|
+
/** The connection, or undefined when there is none — or the file is not one, which is the same as none. */
|
|
30
|
+
var readConnection = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(await fs.readFile(connectionFile(), "utf-8"));
|
|
33
|
+
return isConnection(parsed) ? parsed : void 0;
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var writeConnection = async (connection) => {
|
|
39
|
+
await fs.mkdir(configDir(), {
|
|
40
|
+
recursive: true,
|
|
41
|
+
mode: 448
|
|
42
|
+
});
|
|
43
|
+
await fs.writeFile(connectionFile(), `${JSON.stringify(connection, null, 2)}\n`, { mode: 384 });
|
|
44
|
+
await fs.chmod(connectionFile(), 384);
|
|
45
|
+
};
|
|
46
|
+
var forgetConnection = async () => {
|
|
47
|
+
await fs.rm(connectionFile(), { force: true });
|
|
48
|
+
};
|
|
49
|
+
/** An API address as it is compared and stored: its origin plus any path, no trailing slash. */
|
|
50
|
+
var normalizeApi = (value) => {
|
|
51
|
+
const url = new URL(value);
|
|
52
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Which platform a command talks to: `--api`, else `PLITZI_API_URL`, else the one connected to, else Plitzi's own.
|
|
56
|
+
* Undefined when what was given is not an address.
|
|
57
|
+
*/
|
|
58
|
+
var resolveApi = (given, connection) => {
|
|
59
|
+
const chosen = given ?? process.env.PLITZI_API_URL ?? connection?.api ?? "https://api.plitzi.com";
|
|
60
|
+
try {
|
|
61
|
+
return normalizeApi(chosen);
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/account/http.ts
|
|
68
|
+
/**
|
|
69
|
+
* The CLI's requests to the platform, and what it says when one never arrives.
|
|
70
|
+
*
|
|
71
|
+
* Node's `fetch` carries its own bundled certificate authorities and knows nothing about the machine's, so a server
|
|
72
|
+
* whose certificate the machine trusts — a development one issued by a local CA, a company's own — is refused with
|
|
73
|
+
* `UNABLE_TO_VERIFY_LEAF_SIGNATURE` while the browser beside it talks to the same host happily. The desktop app met
|
|
74
|
+
* this first; here the fix is to trust what the system trusts, as the browser the sign-in happens in does.
|
|
75
|
+
*/
|
|
76
|
+
var trusted = false;
|
|
77
|
+
/** The system's certificate authorities, added to Node's own. Once per process; a no-op where Node cannot do it. */
|
|
78
|
+
var trustSystemCertificates = () => {
|
|
79
|
+
if (trusted) return;
|
|
80
|
+
trusted = true;
|
|
81
|
+
try {
|
|
82
|
+
tls.setDefaultCACertificates([...tls.getCACertificates("default"), ...tls.getCACertificates("system")]);
|
|
83
|
+
} catch {}
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Why a request never reached anyone, in words somebody can act on.
|
|
87
|
+
*
|
|
88
|
+
* `fetch` reports every transport failure as the same "fetch failed"; the useful part is on `cause`. A certificate this
|
|
89
|
+
* machine will not verify and a server that is not running are the two that actually happen.
|
|
90
|
+
*/
|
|
91
|
+
var unreachable = (url, error) => {
|
|
92
|
+
const cause = error.cause;
|
|
93
|
+
const detail = cause?.code ?? cause?.message ?? (error instanceof Error ? error.message : String(error));
|
|
94
|
+
return `Could not reach ${new URL(url).origin} (${detail}).`;
|
|
95
|
+
};
|
|
96
|
+
/** One request, answered as JSON. A gateway's HTML page is said as such rather than read as the server refusing. */
|
|
97
|
+
var requestJson = async (url, init = {}) => {
|
|
98
|
+
trustSystemCertificates();
|
|
99
|
+
let response;
|
|
100
|
+
try {
|
|
101
|
+
response = await fetch(url, init);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: unreachable(url, error)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const text = await response.text();
|
|
109
|
+
try {
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
status: response.status,
|
|
113
|
+
data: text ? JSON.parse(text) : {}
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `${new URL(url).pathname} answered ${response.status}, and not with JSON.`
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var postForm = (url, body) => requestJson(url, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
126
|
+
Accept: "application/json"
|
|
127
|
+
},
|
|
128
|
+
body: new URLSearchParams(body).toString()
|
|
129
|
+
});
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/account/oauth.ts
|
|
132
|
+
/**
|
|
133
|
+
* Signing in the way a native application is supposed to: in the person's browser, never at this prompt.
|
|
134
|
+
*
|
|
135
|
+
* The CLI opens the platform's `/authorize`, waits on a loopback address for the answer, and exchanges a one-shot code
|
|
136
|
+
* for the person's session. It never sees a password — so it cannot store one or leak one; MFA and social sign-in work
|
|
137
|
+
* without it knowing they exist; and a session revoked from the account's device list is revoked here.
|
|
138
|
+
*
|
|
139
|
+
* The same flow the desktop app runs (`apps/desktop/electron/signIn.ts`), against the same authorization server: the
|
|
140
|
+
* platform's auth is the one place credentials are granted. Choosing a space is part of it too — asked with the `space`
|
|
141
|
+
* scope, the choice is made on the grant screen and comes back beside the session as the grant's `target`.
|
|
142
|
+
*/
|
|
143
|
+
var base64url = (buffer) => buffer.toString("base64url");
|
|
144
|
+
/**
|
|
145
|
+
* What the account's device list calls this sign-in: the CLI, and the machine it runs on — two laptops signed in are
|
|
146
|
+
* two rows somebody has to tell apart. macOS names a machine `Carlos-MacBook-Pro.local`; the suffix says nothing.
|
|
147
|
+
*/
|
|
148
|
+
var clientName = () => {
|
|
149
|
+
const host = os.hostname().replace(/\.local$/iu, "");
|
|
150
|
+
return host ? `Plitzi CLI on ${host}` : "Plitzi CLI";
|
|
151
|
+
};
|
|
152
|
+
/** How long the terminal waits for somebody to finish in the browser before giving the port back. */
|
|
153
|
+
var FLOW_TIMEOUT_MS = 3e5;
|
|
154
|
+
var discover = async (apiUrl) => {
|
|
155
|
+
const reply = await requestJson(`${apiUrl}/.well-known/oauth-authorization-server`);
|
|
156
|
+
if (!reply.ok) return reply;
|
|
157
|
+
const { authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint } = reply.data;
|
|
158
|
+
if (reply.status !== 200 || !authorization_endpoint || !token_endpoint || !registration_endpoint) return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `${apiUrl} does not offer signing in from the command line.`
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
endpoints: {
|
|
165
|
+
authorization_endpoint,
|
|
166
|
+
token_endpoint,
|
|
167
|
+
registration_endpoint,
|
|
168
|
+
revocation_endpoint
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
var page = (title, message) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
173
|
+
<body style="font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;color-scheme:light dark">
|
|
174
|
+
<p>${message}</p>`;
|
|
175
|
+
var DONE_PAGE = page("Signed in", "Done. You can close this tab and go back to your terminal.");
|
|
176
|
+
var FAILED_PAGE = page("Sign-in failed", "That did not complete. Go back to your terminal and try again.");
|
|
177
|
+
/**
|
|
178
|
+
* A loopback listener that resolves with the code the browser brings back.
|
|
179
|
+
*
|
|
180
|
+
* On 127.0.0.1 explicitly rather than every interface: a server on `0.0.0.0` is reachable from the network, and what
|
|
181
|
+
* it holds is an authorization code. `state` tells this flow's answer from anything else that reaches the port.
|
|
182
|
+
*/
|
|
183
|
+
var awaitCode = (state) => new Promise((resolve) => {
|
|
184
|
+
let settle = () => void 0;
|
|
185
|
+
const code = new Promise((done) => {
|
|
186
|
+
settle = done;
|
|
187
|
+
});
|
|
188
|
+
const server = createServer((req, res) => {
|
|
189
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
190
|
+
if (url.pathname !== "/callback") {
|
|
191
|
+
res.writeHead(404).end();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const returned = url.searchParams.get("code") ?? void 0;
|
|
195
|
+
const ok = returned !== void 0 && url.searchParams.get("state") === state;
|
|
196
|
+
res.writeHead(ok ? 200 : 400, {
|
|
197
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
198
|
+
Connection: "close"
|
|
199
|
+
});
|
|
200
|
+
res.end(ok ? DONE_PAGE : FAILED_PAGE);
|
|
201
|
+
settle(ok ? returned : void 0);
|
|
202
|
+
});
|
|
203
|
+
const close = () => new Promise((done) => {
|
|
204
|
+
server.closeAllConnections();
|
|
205
|
+
server.close(() => done());
|
|
206
|
+
});
|
|
207
|
+
server.listen(0, "127.0.0.1", () => {
|
|
208
|
+
resolve({
|
|
209
|
+
close,
|
|
210
|
+
port: server.address().port,
|
|
211
|
+
code
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
/**
|
|
216
|
+
* The person's own browser, at `url`. Printed as well by the caller: over SSH, or with no browser at all, the link is
|
|
217
|
+
* opened by hand — and a failure to start one here is nothing to stop over.
|
|
218
|
+
*/
|
|
219
|
+
var openBrowser = (url) => {
|
|
220
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
221
|
+
try {
|
|
222
|
+
const child = spawn(command, args, {
|
|
223
|
+
stdio: "ignore",
|
|
224
|
+
detached: true
|
|
225
|
+
});
|
|
226
|
+
child.on("error", () => void 0);
|
|
227
|
+
child.unref();
|
|
228
|
+
} catch {}
|
|
229
|
+
};
|
|
230
|
+
var grantOf = (clientId, token, previousRefresh) => {
|
|
231
|
+
if (!token.access_token) return {
|
|
232
|
+
ok: false,
|
|
233
|
+
reason: "refused",
|
|
234
|
+
error: token.error_description ?? token.error
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
grant: {
|
|
239
|
+
clientId,
|
|
240
|
+
accessToken: token.access_token,
|
|
241
|
+
refreshToken: token.refresh_token ?? previousRefresh,
|
|
242
|
+
expiresAt: token.expires_in !== void 0 ? Math.floor(Date.now() / 1e3) + token.expires_in : void 0,
|
|
243
|
+
target: token.target
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
var signInThroughBrowser = async (apiUrl, { scope, open }) => {
|
|
248
|
+
const discovered = await discover(apiUrl);
|
|
249
|
+
if (!discovered.ok) return {
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: "unreachable",
|
|
252
|
+
error: discovered.error
|
|
253
|
+
};
|
|
254
|
+
const { endpoints } = discovered;
|
|
255
|
+
const verifier = base64url(randomBytes(32));
|
|
256
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
257
|
+
const state = base64url(randomBytes(16));
|
|
258
|
+
const { close, port, code } = await awaitCode(state);
|
|
259
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
260
|
+
try {
|
|
261
|
+
const registered = await requestJson(endpoints.registration_endpoint, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: {
|
|
264
|
+
"Content-Type": "application/json",
|
|
265
|
+
Accept: "application/json"
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify({
|
|
268
|
+
client_name: clientName(),
|
|
269
|
+
software_id: "plitzi-cli",
|
|
270
|
+
redirect_uris: [redirectUri]
|
|
271
|
+
})
|
|
272
|
+
});
|
|
273
|
+
if (!registered.ok) return {
|
|
274
|
+
ok: false,
|
|
275
|
+
reason: "unreachable",
|
|
276
|
+
error: registered.error
|
|
277
|
+
};
|
|
278
|
+
const clientId = registered.data.client_id;
|
|
279
|
+
if (!clientId) return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: "refused",
|
|
282
|
+
error: registered.data.error_description ?? registered.data.error ?? "The server would not register the CLI."
|
|
283
|
+
};
|
|
284
|
+
const authorize = new URL(endpoints.authorization_endpoint);
|
|
285
|
+
authorize.search = new URLSearchParams({
|
|
286
|
+
response_type: "code",
|
|
287
|
+
client_id: clientId,
|
|
288
|
+
redirect_uri: redirectUri,
|
|
289
|
+
code_challenge: challenge,
|
|
290
|
+
code_challenge_method: "S256",
|
|
291
|
+
state,
|
|
292
|
+
...scope ? { scope } : {}
|
|
293
|
+
}).toString();
|
|
294
|
+
open(authorize.toString());
|
|
295
|
+
let timer;
|
|
296
|
+
const timeout = new Promise((done) => {
|
|
297
|
+
timer = setTimeout(() => done(void 0), FLOW_TIMEOUT_MS);
|
|
298
|
+
});
|
|
299
|
+
const returned = await Promise.race([code, timeout]);
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
if (!returned) return {
|
|
302
|
+
ok: false,
|
|
303
|
+
reason: "timeout"
|
|
304
|
+
};
|
|
305
|
+
const exchanged = await postForm(endpoints.token_endpoint, {
|
|
306
|
+
grant_type: "authorization_code",
|
|
307
|
+
code: returned,
|
|
308
|
+
redirect_uri: redirectUri,
|
|
309
|
+
code_verifier: verifier,
|
|
310
|
+
client_id: clientId
|
|
311
|
+
});
|
|
312
|
+
if (!exchanged.ok) return {
|
|
313
|
+
ok: false,
|
|
314
|
+
reason: "unreachable",
|
|
315
|
+
error: exchanged.error
|
|
316
|
+
};
|
|
317
|
+
return grantOf(clientId, exchanged.data);
|
|
318
|
+
} finally {
|
|
319
|
+
await close();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
/** A new session for the same grant. The CLI holds a refresh token and never the person's password. */
|
|
323
|
+
var renewGrant = async (apiUrl, grant) => {
|
|
324
|
+
if (!grant.refreshToken) return {
|
|
325
|
+
ok: false,
|
|
326
|
+
reason: "refused",
|
|
327
|
+
error: "This session cannot be renewed."
|
|
328
|
+
};
|
|
329
|
+
const discovered = await discover(apiUrl);
|
|
330
|
+
if (!discovered.ok) return {
|
|
331
|
+
ok: false,
|
|
332
|
+
reason: "unreachable",
|
|
333
|
+
error: discovered.error
|
|
334
|
+
};
|
|
335
|
+
const renewed = await postForm(discovered.endpoints.token_endpoint, {
|
|
336
|
+
grant_type: "refresh_token",
|
|
337
|
+
refresh_token: grant.refreshToken,
|
|
338
|
+
client_id: grant.clientId
|
|
339
|
+
});
|
|
340
|
+
if (!renewed.ok) return {
|
|
341
|
+
ok: false,
|
|
342
|
+
reason: "unreachable",
|
|
343
|
+
error: renewed.error
|
|
344
|
+
};
|
|
345
|
+
return grantOf(grant.clientId, renewed.data, grant.refreshToken);
|
|
346
|
+
};
|
|
347
|
+
/**
|
|
348
|
+
* Ending the grant, not just forgetting it here: the refresh token could mint another session, so a sign-out that only
|
|
349
|
+
* deleted the file would leave a credential alive that a copy of the file could still use. RFC 7009 answers success
|
|
350
|
+
* either way, so this answers nothing.
|
|
351
|
+
*/
|
|
352
|
+
var revokeGrant = async (apiUrl, grant) => {
|
|
353
|
+
const discovered = await discover(apiUrl);
|
|
354
|
+
if (!discovered.ok || !discovered.endpoints.revocation_endpoint || !grant.refreshToken) return;
|
|
355
|
+
await postForm(discovered.endpoints.revocation_endpoint, {
|
|
356
|
+
token: grant.refreshToken,
|
|
357
|
+
token_type_hint: "refresh_token",
|
|
358
|
+
client_id: grant.clientId
|
|
359
|
+
});
|
|
360
|
+
};
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/account/session.ts
|
|
363
|
+
/** Renewed this long before it expires, so a session is not handed to a request it will not outlive. */
|
|
364
|
+
var RENEW_MARGIN_SECONDS = 60;
|
|
365
|
+
var SPACE_TARGET_PREFIX = "space:";
|
|
366
|
+
var bearer = (grant) => ({
|
|
367
|
+
Authorization: `Bearer ${grant.accessToken}`,
|
|
368
|
+
Accept: "application/json"
|
|
369
|
+
});
|
|
370
|
+
/** The space a grant was given for, by the id its target carries, named as the platform names it. */
|
|
371
|
+
var spaceOfGrant = async (api, grant) => {
|
|
372
|
+
if (!grant.target?.startsWith(SPACE_TARGET_PREFIX)) return {
|
|
373
|
+
ok: true,
|
|
374
|
+
value: void 0
|
|
375
|
+
};
|
|
376
|
+
const id = grant.target.slice(6);
|
|
377
|
+
const reply = await requestJson(`${api}/spaces/${encodeURIComponent(id)}?logsLimit=1`, { headers: bearer(grant) });
|
|
378
|
+
if (!reply.ok) return reply;
|
|
379
|
+
const space = reply.data.space;
|
|
380
|
+
if (reply.status !== 200 || typeof space?.id !== "number" || typeof space.permanentUrl !== "string") return {
|
|
381
|
+
ok: false,
|
|
382
|
+
error: `The space you chose could not be read (${reply.status}).`
|
|
383
|
+
};
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
value: {
|
|
387
|
+
id: space.id,
|
|
388
|
+
name: space.name || space.permanentUrl,
|
|
389
|
+
permanentUrl: space.permanentUrl
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* A new connection, made in the browser, REPLACING whatever connection there was — another space, another account,
|
|
395
|
+
* another platform. The old grant is revoked rather than left alive in nobody's hands.
|
|
396
|
+
*/
|
|
397
|
+
var connect$2 = async (api, { scope, open }) => {
|
|
398
|
+
const signedIn = await signInThroughBrowser(api, {
|
|
399
|
+
scope,
|
|
400
|
+
open
|
|
401
|
+
});
|
|
402
|
+
if (!signedIn.ok) return {
|
|
403
|
+
ok: false,
|
|
404
|
+
error: signedIn.reason === "timeout" ? "Nothing came back from the browser in five minutes. Run the command again when you are ready." : signedIn.error ?? "The platform refused the sign-in."
|
|
405
|
+
};
|
|
406
|
+
const { grant } = signedIn;
|
|
407
|
+
const space = await spaceOfGrant(api, grant);
|
|
408
|
+
if (!space.ok) {
|
|
409
|
+
await revokeGrant(api, grant);
|
|
410
|
+
return space;
|
|
411
|
+
}
|
|
412
|
+
const previous = await readConnection();
|
|
413
|
+
const connection = {
|
|
414
|
+
api,
|
|
415
|
+
grant,
|
|
416
|
+
...space.value ? { space: space.value } : {}
|
|
417
|
+
};
|
|
418
|
+
await writeConnection(connection);
|
|
419
|
+
if (previous) await revokeGrant(previous.api, previous.grant);
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
value: connection
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
/** Signing out: the grant revoked at the platform, and the connection forgotten here. */
|
|
426
|
+
var disconnect = async () => {
|
|
427
|
+
const connection = await readConnection();
|
|
428
|
+
if (connection) {
|
|
429
|
+
await revokeGrant(connection.api, connection.grant);
|
|
430
|
+
await forgetConnection();
|
|
431
|
+
}
|
|
432
|
+
return connection;
|
|
433
|
+
};
|
|
434
|
+
/** Why a connection is gone, said with the command that makes it again. */
|
|
435
|
+
var ended = (connection) => connection.space ? `Your connection to ${connection.space.name} has ended — signed out elsewhere, or you no longer have access to that space. Connect again with plitzi space.` : "Your session has ended. Sign in again with plitzi login.";
|
|
436
|
+
/** A fresh session for the connection, kept. A refusal ends the connection; an unreachable server does not. */
|
|
437
|
+
var renew = async (connection) => {
|
|
438
|
+
const renewed = await renewGrant(connection.api, connection.grant);
|
|
439
|
+
if (renewed.ok) {
|
|
440
|
+
const next = {
|
|
441
|
+
...connection,
|
|
442
|
+
grant: renewed.grant
|
|
443
|
+
};
|
|
444
|
+
await writeConnection(next);
|
|
445
|
+
return {
|
|
446
|
+
ok: true,
|
|
447
|
+
value: next
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (renewed.reason === "refused") {
|
|
451
|
+
await forgetConnection();
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
error: ended(connection)
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
error: renewed.error ?? `Could not reach ${connection.api}.`
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
var nearlyExpired = (grant) => grant.expiresAt !== void 0 && grant.expiresAt - RENEW_MARGIN_SECONDS <= Math.floor(Date.now() / 1e3);
|
|
463
|
+
/**
|
|
464
|
+
* The connection to `api`, renewed if it is about to expire. Undefined when there is none there — including when the
|
|
465
|
+
* CLI is connected, but to another platform: that one is not used for this.
|
|
466
|
+
*/
|
|
467
|
+
var currentConnection = async (api) => {
|
|
468
|
+
const connection = await readConnection();
|
|
469
|
+
if (connection?.api !== api) return {
|
|
470
|
+
ok: true,
|
|
471
|
+
value: void 0
|
|
472
|
+
};
|
|
473
|
+
return nearlyExpired(connection.grant) ? renew(connection) : {
|
|
474
|
+
ok: true,
|
|
475
|
+
value: connection
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* A request as the connected person. A 401 renews the session and tries once more; a second 401 ends the connection,
|
|
480
|
+
* since the server has now said twice that the credential does not work.
|
|
481
|
+
*/
|
|
482
|
+
var authorizedRequest = async (connection, pathname, init = {}) => {
|
|
483
|
+
const send = (current) => requestJson(`${current.api}${pathname}`, {
|
|
484
|
+
...init,
|
|
485
|
+
headers: {
|
|
486
|
+
...bearer(current.grant),
|
|
487
|
+
...init.headers
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
const first = await send(connection);
|
|
491
|
+
if (!first.ok) return first;
|
|
492
|
+
if (first.status !== 401) return {
|
|
493
|
+
ok: true,
|
|
494
|
+
value: {
|
|
495
|
+
connection,
|
|
496
|
+
reply: first
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const renewed = await renew(connection);
|
|
500
|
+
if (!renewed.ok) return renewed;
|
|
501
|
+
const second = await send(renewed.value);
|
|
502
|
+
if (!second.ok) return second;
|
|
503
|
+
if (second.status === 401) {
|
|
504
|
+
await forgetConnection();
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: ended(connection)
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
value: {
|
|
513
|
+
connection: renewed.value,
|
|
514
|
+
reply: second
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
};
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/commands/account.ts
|
|
520
|
+
/** The address to open, printed as well: over SSH, or with no browser to start, it is opened by hand. */
|
|
521
|
+
var openInBrowser = (url) => {
|
|
522
|
+
console.log(`\nContinue in your browser. If it did not open, go to:\n ${chalk.cyan(url)}`);
|
|
523
|
+
openBrowser(url);
|
|
524
|
+
};
|
|
525
|
+
/** The platform this command talks to, or undefined — said — when `--api` or `PLITZI_API_URL` is not an address. */
|
|
526
|
+
var apiFor = async (options) => {
|
|
527
|
+
const api = resolveApi(options.api, await readConnection());
|
|
528
|
+
if (!api) {
|
|
529
|
+
console.error(chalk.red(`"${options.api ?? process.env.PLITZI_API_URL ?? ""}" is not an address.`));
|
|
530
|
+
process.exitCode = 1;
|
|
531
|
+
}
|
|
532
|
+
return api;
|
|
533
|
+
};
|
|
534
|
+
var fail = (error) => {
|
|
535
|
+
console.error(chalk.red(error));
|
|
536
|
+
process.exitCode = 1;
|
|
537
|
+
};
|
|
538
|
+
/** Who the connection signs in as, asked of the platform — which also proves the session still works. */
|
|
539
|
+
var whoIs = async (connection) => {
|
|
540
|
+
const answered = await authorizedRequest(connection, "/auth/session");
|
|
541
|
+
if (!answered.ok) return answered;
|
|
542
|
+
const { reply } = answered.value;
|
|
543
|
+
if (reply.status !== 200) return {
|
|
544
|
+
ok: false,
|
|
545
|
+
error: `${connection.api} would not say who you are (${reply.status}).`
|
|
546
|
+
};
|
|
547
|
+
return {
|
|
548
|
+
ok: true,
|
|
549
|
+
value: reply.data.details?.email || reply.data.details?.username || "you"
|
|
550
|
+
};
|
|
551
|
+
};
|
|
552
|
+
var spaceLine = (connection) => connection.space ? `Space: ${chalk.bold(connection.space.name)} ${chalk.dim(`(${connection.space.permanentUrl})`)}` : chalk.dim("No space chosen: choose one with plitzi space.");
|
|
553
|
+
var login = async (options) => {
|
|
554
|
+
const api = await apiFor(options);
|
|
555
|
+
if (!api) return;
|
|
556
|
+
const current = await currentConnection(api);
|
|
557
|
+
if (current.ok && current.value) {
|
|
558
|
+
const who = await whoIs(current.value);
|
|
559
|
+
if (who.ok) {
|
|
560
|
+
console.log(`\nSigned in to ${api} as ${chalk.bold(who.value)} already.`);
|
|
561
|
+
console.log(chalk.dim("To sign in as somebody else: plitzi logout, then plitzi login.\n"));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const previous = await readConnection();
|
|
566
|
+
const connected = await connect$2(api, { open: openInBrowser });
|
|
567
|
+
if (!connected.ok) {
|
|
568
|
+
fail(connected.error);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const who = await whoIs(connected.value);
|
|
572
|
+
console.log(chalk.green(`\nSigned in to ${api}${who.ok ? ` as ${who.value}` : ""}.`));
|
|
573
|
+
if (previous && previous.api !== api) console.log(chalk.dim(`The CLI is connected to one platform at a time: ${previous.api} is signed out.`));
|
|
574
|
+
console.log(`${spaceLine(connected.value)}\n`);
|
|
575
|
+
};
|
|
576
|
+
var logout = async () => {
|
|
577
|
+
const connection = await disconnect();
|
|
578
|
+
console.log(connection ? `\nSigned out of ${connection.api}.\n` : "\nNot signed in.\n");
|
|
579
|
+
};
|
|
580
|
+
var whoami = async (options) => {
|
|
581
|
+
const api = await apiFor(options);
|
|
582
|
+
if (!api) return;
|
|
583
|
+
const current = await currentConnection(api);
|
|
584
|
+
if (!current.ok) {
|
|
585
|
+
fail(current.error);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (!current.value) {
|
|
589
|
+
fail(`Not signed in to ${api}. Sign in with plitzi login.`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const who = await whoIs(current.value);
|
|
593
|
+
if (!who.ok) {
|
|
594
|
+
fail(who.error);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
console.log(`\nSigned in to ${api} as ${chalk.bold(who.value)}.`);
|
|
598
|
+
console.log(`${spaceLine(current.value)}\n`);
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* The space to work in, chosen on the platform's grant screen. The connection is REPLACED by the new grant — the CLI
|
|
602
|
+
* works in one space at a time, and the space it worked in before is no longer reachable from it.
|
|
603
|
+
*/
|
|
604
|
+
var chooseSpace = async (api) => {
|
|
605
|
+
const connected = await connect$2(api, {
|
|
606
|
+
scope: "space",
|
|
607
|
+
open: openInBrowser
|
|
608
|
+
});
|
|
609
|
+
if (connected.ok && !connected.value.space) return {
|
|
610
|
+
ok: false,
|
|
611
|
+
error: "No space was chosen."
|
|
612
|
+
};
|
|
613
|
+
return connected;
|
|
614
|
+
};
|
|
615
|
+
var space = async (options) => {
|
|
616
|
+
const api = await apiFor(options);
|
|
617
|
+
if (!api) return;
|
|
618
|
+
const connected = await chooseSpace(api);
|
|
619
|
+
if (!connected.ok) {
|
|
620
|
+
fail(connected.error);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
console.log(chalk.green(`\nConnected to ${api}.`));
|
|
624
|
+
console.log(`${spaceLine(connected.value)}\n`);
|
|
625
|
+
};
|
|
626
|
+
/**
|
|
627
|
+
* The connection to work through: the one there is when it has a space, else one made in the browser now — so the first
|
|
628
|
+
* command that needs a space is one command too. `doing` finishes the prompt: "to upload to", "to pull from".
|
|
629
|
+
*/
|
|
630
|
+
var connectionWithSpace = async (api, doing = "to work in") => {
|
|
631
|
+
const current = await currentConnection(api);
|
|
632
|
+
if (!current.ok) {
|
|
633
|
+
fail(current.error);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (current.value?.space) return current.value;
|
|
637
|
+
console.log(current.value ? `\nChoose the space ${doing}.` : `\nSign in, and choose the space ${doing}.`);
|
|
638
|
+
const chosen = await chooseSpace(api);
|
|
639
|
+
if (!chosen.ok) {
|
|
640
|
+
fail(chosen.error);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
return chosen.value;
|
|
644
|
+
};
|
|
645
|
+
//#endregion
|
|
11
646
|
//#region src/scaffold/client.ts
|
|
12
647
|
/**
|
|
13
648
|
* Client mode: the SDK renders in the browser and there is no server at all.
|
|
@@ -16,7 +651,7 @@ import { blankSpaceSource } from "@plitzi/sdk-authoring";
|
|
|
16
651
|
* page without reloading it. Nothing here is a build artefact of Plitzi's: the page is this project's, and the
|
|
17
652
|
* SDK is a dependency it imports.
|
|
18
653
|
*/
|
|
19
|
-
var indexHtml = ({ name }) => `<!doctype html>
|
|
654
|
+
var indexHtml$1 = ({ name }) => `<!doctype html>
|
|
20
655
|
<html lang="en">
|
|
21
656
|
<head>
|
|
22
657
|
<meta charset="UTF-8" />
|
|
@@ -29,7 +664,7 @@ var indexHtml = ({ name }) => `<!doctype html>
|
|
|
29
664
|
</body>
|
|
30
665
|
</html>
|
|
31
666
|
`;
|
|
32
|
-
var viteConfig = () => `import { createReadStream } from 'node:fs';
|
|
667
|
+
var viteConfig$1 = () => `import { createReadStream } from 'node:fs';
|
|
33
668
|
import { createRequire } from 'node:module';
|
|
34
669
|
|
|
35
670
|
import { defineConfig } from 'vite';
|
|
@@ -72,7 +707,7 @@ export default defineConfig({
|
|
|
72
707
|
server: { host: '127.0.0.1', port: 5173 }
|
|
73
708
|
});
|
|
74
709
|
`;
|
|
75
|
-
var preflightCss = () => `/*
|
|
710
|
+
var preflightCss$1 = () => `/*
|
|
76
711
|
* The SDK ships no global CSS on purpose: dropping a space into an existing site must not restyle that site.
|
|
77
712
|
* The browser's own margins therefore survive unless the host page clears them, which is what this does.
|
|
78
713
|
*/
|
|
@@ -90,25 +725,35 @@ body {
|
|
|
90
725
|
* Written into both entry points, because how a plugin is registered does not change with where the space lives.
|
|
91
726
|
*/
|
|
92
727
|
var PLUGINS$1 = `/**
|
|
93
|
-
* The project's own components
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* \`src/plugins\` and adding a line here; see \`src/plugins/README.md\`.
|
|
728
|
+
* The project's own components: every folder of \`src/plugins\` is one, registered under its name in camelCase —
|
|
729
|
+
* \`src/plugins/StatCard\` is what a space's \`custom({ renderType: 'statCard' })\` renders, and its attributes arrive
|
|
730
|
+
* as the component's props. \`plitzi add plugin\` writes a new one there, and Vite picks it up. There is no server
|
|
731
|
+
* here, so the plugins are part of this project's bundle and hot-replaced like any other module; see
|
|
732
|
+
* \`src/plugins/README.md\`.
|
|
99
733
|
*/
|
|
100
|
-
const
|
|
734
|
+
const pluginModules = import.meta.glob<{ default: RenderPlugins[string]['component'] }>('./plugins/*/index.ts', {
|
|
735
|
+
eager: true
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const plugins: RenderPlugins = Object.fromEntries(
|
|
739
|
+
Object.entries(pluginModules).map(([file, module]) => {
|
|
740
|
+
const folder = file.split('/')[2];
|
|
741
|
+
|
|
742
|
+
return [\`\${folder.charAt(0).toLowerCase()}\${folder.slice(1)}\`, { component: module.default }];
|
|
743
|
+
})
|
|
744
|
+
);`;
|
|
101
745
|
/** The two ways the space reaches the entry point, written once because both modes phrase them identically. */
|
|
102
746
|
var localMain$1 = () => `import { render } from '@plitzi/plitzi-sdk';
|
|
103
747
|
|
|
104
748
|
import { authorSpace } from '@plitzi/sdk-authoring';
|
|
105
749
|
|
|
106
|
-
import
|
|
107
|
-
import { space } from './space';
|
|
750
|
+
import { declarations } from './plugins/declarations.ts';
|
|
751
|
+
import { space } from './space.ts';
|
|
108
752
|
|
|
109
753
|
import './preflight.css';
|
|
110
754
|
import '@plitzi/plitzi-sdk/plitzi-sdk.css';
|
|
111
755
|
|
|
756
|
+
import type { RenderPlugins } from '@plitzi/plitzi-sdk';
|
|
112
757
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
113
758
|
|
|
114
759
|
${PLUGINS$1}
|
|
@@ -128,7 +773,7 @@ const mount = (spec: SpaceSpec) =>
|
|
|
128
773
|
* space, so there is no account, no key and no server in the picture.
|
|
129
774
|
*/
|
|
130
775
|
offlineMode: true,
|
|
131
|
-
offlineData: authorSpace(spec),
|
|
776
|
+
offlineData: authorSpace(spec, { plugins: declarations }),
|
|
132
777
|
/**
|
|
133
778
|
* Without this the SDK renders inside an IFRAME — its default, because a space dropped into an unknown page
|
|
134
779
|
* is safest isolated from it. This page is yours, so render straight into the DOM: one document, one
|
|
@@ -169,11 +814,11 @@ if (import.meta.hot) {
|
|
|
169
814
|
`;
|
|
170
815
|
var cloudMain$1 = () => `import { render } from '@plitzi/plitzi-sdk';
|
|
171
816
|
|
|
172
|
-
import StatCard from './plugins/StatCard';
|
|
173
|
-
|
|
174
817
|
import './preflight.css';
|
|
175
818
|
import '@plitzi/plitzi-sdk/plitzi-sdk.css';
|
|
176
819
|
|
|
820
|
+
import type { RenderPlugins } from '@plitzi/plitzi-sdk';
|
|
821
|
+
|
|
177
822
|
${PLUGINS$1}
|
|
178
823
|
|
|
179
824
|
/**
|
|
@@ -202,9 +847,9 @@ render(
|
|
|
202
847
|
);
|
|
203
848
|
`;
|
|
204
849
|
var clientFiles = (answers) => ({
|
|
205
|
-
"index.html": indexHtml(answers),
|
|
206
|
-
"vite.config.ts": viteConfig(),
|
|
207
|
-
"src/preflight.css": preflightCss(),
|
|
850
|
+
"index.html": indexHtml$1(answers),
|
|
851
|
+
"vite.config.ts": viteConfig$1(),
|
|
852
|
+
"src/preflight.css": preflightCss$1(),
|
|
208
853
|
"src/main.ts": answers.source === "cloud" ? cloudMain$1() : localMain$1()
|
|
209
854
|
});
|
|
210
855
|
//#endregion
|
|
@@ -223,7 +868,7 @@ var clientFiles = (answers) => ({
|
|
|
223
868
|
* without a line of plumbing in between. Plitzi's own server-side plugins (the dashboard's world map and its live
|
|
224
869
|
* traffic view) are written to exactly this shape.
|
|
225
870
|
*/
|
|
226
|
-
var component = () => `import { useMemo, useState } from 'react';
|
|
871
|
+
var component$1 = () => `import { useMemo, useState } from 'react';
|
|
227
872
|
|
|
228
873
|
import { RootElement } from '@plitzi/plitzi-sdk';
|
|
229
874
|
|
|
@@ -363,27 +1008,32 @@ export * from './StatCard';
|
|
|
363
1008
|
|
|
364
1009
|
export default StatCard;
|
|
365
1010
|
`;
|
|
366
|
-
var readme$
|
|
1011
|
+
var readme$2 = ({ mode }) => `# Plugins
|
|
367
1012
|
|
|
368
1013
|
Components of your own, rendered by the space.
|
|
369
1014
|
|
|
370
|
-
|
|
371
|
-
element naming that
|
|
372
|
-
element's attributes arrive as the component's props.
|
|
1015
|
+
Every folder here is one, registered under its name in camelCase: \`StatCard\` is the \`renderType\` \`statCard\`. The
|
|
1016
|
+
space hosts it with a \`custom\` element naming that type — see \`custom({ renderType: 'statCard', … })\` in
|
|
1017
|
+
\`src/space.ts\` — and the element's attributes arrive as the component's props.
|
|
373
1018
|
|
|
374
|
-
${mode === "server" ? `\`src/main.ts\` registers
|
|
1019
|
+
${mode === "server" ? `\`src/main.ts\` registers each one with \`action: 'compile'\`, which is what makes it **server-rendered**: the
|
|
375
1020
|
server builds the entry with esbuild, keeps React external so the plugin runs on the one copy the page already
|
|
376
1021
|
has, serves the bundle to the browser AND imports it into the render — so the component's markup is in the HTML
|
|
377
1022
|
before any JavaScript arrives. A plugin registered any other way renders only after hydration, which is a hole in
|
|
378
|
-
the document for anyone reading the page before then.` : `\`src/main.ts\`
|
|
379
|
-
|
|
1023
|
+
the document for anyone reading the page before then.` : `\`src/main.ts\` hands them to \`render()\`. There is no server here, so each one is part of this project's own
|
|
1024
|
+
bundle and Vite hot-replaces it like any other module.`}
|
|
380
1025
|
|
|
381
1026
|
## Adding another
|
|
382
1027
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
1028
|
+
\`\`\`bash
|
|
1029
|
+
npx @plitzi/cli add plugin seat-picker
|
|
1030
|
+
\`\`\`
|
|
1031
|
+
|
|
1032
|
+
It asks what to call it and writes \`src/plugins/SeatPicker/\`: the component, the panel the builder edits it with, and
|
|
1033
|
+
the \`index.ts\` that hands both over. Then put a \`custom({ renderType: 'seatPicker', … })\` in \`src/space.ts\`.
|
|
1034
|
+
|
|
1035
|
+
By hand it is the same three files: \`YourThing/YourThing.tsx\` — a component whose props are the attributes you want
|
|
1036
|
+
to author — and \`YourThing/index.ts\` with an \`export default\`.
|
|
387
1037
|
|
|
388
1038
|
## Three things that bite
|
|
389
1039
|
|
|
@@ -399,10 +1049,30 @@ break the plugin, it blanks the page. Put live values in an effect.
|
|
|
399
1049
|
**Do not name colours.** The page carries a palette and a light/dark theme; a plugin that hard-codes \`#111\` is
|
|
400
1050
|
invisible on one of them. Use \`currentColor\` and the space's own \`var(--…)\` variables.
|
|
401
1051
|
`;
|
|
1052
|
+
var declarationName = (component) => `${component.charAt(0).toLowerCase()}${component.slice(1)}`;
|
|
1053
|
+
/**
|
|
1054
|
+
* `src/plugins/declarations.ts`: the declaration of every plugin that has one, which `authorSpace` holds the space to —
|
|
1055
|
+
* a flow on an event a plugin never fires, or an attribute it does not read, is refused instead of written. Written
|
|
1056
|
+
* from the folders' names alone, so the CLI can tell a list it wrote from one somebody changed, and add to the first.
|
|
1057
|
+
*/
|
|
1058
|
+
var projectDeclarations = (components) => {
|
|
1059
|
+
const names = components.map(declarationName);
|
|
1060
|
+
const list = `export const declarations: PluginDeclarationData[] = [${names.join(", ")}];`;
|
|
1061
|
+
return `${components.map((component) => `import ${declarationName(component)} from './${component}/declaration.ts';`).join("\n")}${components.length ? "\n\n" : ""}import type { PluginDeclarationData } from '@plitzi/sdk-authoring';
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Every plugin's declaration, handed to \`authorSpace\` wherever the space is authored: its flows on a plugin's events,
|
|
1065
|
+
* its steps to a plugin's actions and its attributes are checked like a built-in element's. \`plitzi add plugin\`
|
|
1066
|
+
* writes this list; a plugin written by hand is added with its \`declaration.ts\`.
|
|
1067
|
+
*/
|
|
1068
|
+
${list.length <= 120 ? list : `export const declarations: PluginDeclarationData[] = [\n${names.map((name) => ` ${name}`).join(",\n")}\n];`}
|
|
1069
|
+
`;
|
|
1070
|
+
};
|
|
402
1071
|
var pluginFiles = (answers) => ({
|
|
403
|
-
"src/plugins/StatCard/StatCard.tsx": component(),
|
|
1072
|
+
"src/plugins/StatCard/StatCard.tsx": component$1(),
|
|
404
1073
|
"src/plugins/StatCard/index.ts": barrel(),
|
|
405
|
-
"src/plugins/README.md": readme$
|
|
1074
|
+
"src/plugins/README.md": readme$2(answers),
|
|
1075
|
+
...answers.source === "local" ? { "src/plugins/declarations.ts": projectDeclarations([]) } : {}
|
|
406
1076
|
});
|
|
407
1077
|
//#endregion
|
|
408
1078
|
//#region src/scaffold/packageManager.ts
|
|
@@ -435,11 +1105,10 @@ var runCommand = (manager, script) => manager === "npm" && script !== "start" ?
|
|
|
435
1105
|
/**
|
|
436
1106
|
* Yarn's own file, and the only per-manager file the scaffold writes.
|
|
437
1107
|
*
|
|
438
|
-
* Yarn 4 installs Plug'n'Play by default, and
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* two that do and one that fails the moment somebody types `yarn start`.
|
|
1108
|
+
* Yarn 4 installs Plug'n'Play by default, and this project is run straight from `node_modules`: its server by Node
|
|
1109
|
+
* itself, its plugins by the page server's bundler. `node-modules` is the layout npm and pnpm already give it, so
|
|
1110
|
+
* pinning it is what makes all three managers produce a project that runs, rather than two that do and one that
|
|
1111
|
+
* fails the moment somebody types `yarn start`.
|
|
443
1112
|
*/
|
|
444
1113
|
var YARN_LINKER = "nodeLinker: node-modules\n";
|
|
445
1114
|
var YARN_AGE_GATE = `
|
|
@@ -503,8 +1172,6 @@ var managerPackageFields = (manager) => manager === "npm" ? { allowScripts: {
|
|
|
503
1172
|
esbuild: true,
|
|
504
1173
|
fsevents: false
|
|
505
1174
|
} } : {};
|
|
506
|
-
//#endregion
|
|
507
|
-
//#region src/scaffold/project.ts
|
|
508
1175
|
var SDK_VERSION = `^${createRequire(import.meta.url)("@plitzi/sdk-authoring/package.json").version}`;
|
|
509
1176
|
/**
|
|
510
1177
|
* What every generated project builds and checks itself with.
|
|
@@ -513,6 +1180,9 @@ var SDK_VERSION = `^${createRequire(import.meta.url)("@plitzi/sdk-authoring/pack
|
|
|
513
1180
|
* `.tsx` file wherever it renders. The lint stack is the same one Plitzi's own packages use — type-checked rules,
|
|
514
1181
|
* with Prettier owning layout and `eslint-config-prettier` keeping ESLint out of that argument.
|
|
515
1182
|
*/
|
|
1183
|
+
/** The React and the Vite every generated package is written against, project or plugin. */
|
|
1184
|
+
var REACT_VERSION = "^19.2.8";
|
|
1185
|
+
var VITE_VERSION = "^8.2.1";
|
|
516
1186
|
var SHARED_DEV_DEPENDENCIES = {
|
|
517
1187
|
"@eslint/js": "^10.0.1",
|
|
518
1188
|
"@playwright/test": "^1.56.1",
|
|
@@ -539,47 +1209,61 @@ var dependencies = ({ mode, source }) => ({
|
|
|
539
1209
|
"@plitzi/plitzi-sdk": SDK_VERSION,
|
|
540
1210
|
...mode === "server" ? { "@plitzi/sdk-server": SDK_VERSION } : {},
|
|
541
1211
|
...source === "local" ? { "@plitzi/sdk-authoring": SDK_VERSION } : {},
|
|
542
|
-
react:
|
|
543
|
-
"react-dom":
|
|
1212
|
+
react: REACT_VERSION,
|
|
1213
|
+
"react-dom": REACT_VERSION
|
|
544
1214
|
});
|
|
545
|
-
|
|
1215
|
+
/** A server-mode project runs no bundler of its own: the page server builds the plugins, and Node runs the rest. */
|
|
1216
|
+
var devDependencies = ({ mode }) => ({
|
|
546
1217
|
...SHARED_DEV_DEPENDENCIES,
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
1218
|
+
...mode === "server" ? {} : { vite: VITE_VERSION }
|
|
1219
|
+
});
|
|
1220
|
+
/**
|
|
1221
|
+
* Node runs this project's TypeScript itself — the server, `author`, `shot` — by stripping the types, which it does
|
|
1222
|
+
* without a flag from 22.18. Nothing transpiles beside the server: `tsx` did, and its loader thread cost a server
|
|
1223
|
+
* more memory than the server itself (~270 MB to start where the same server starts in ~90), which is the difference
|
|
1224
|
+
* between a small host and one that is killed on boot. The price is the one `tsconfig` enforces: relative imports
|
|
1225
|
+
* name their `.ts` file, and only syntax that erases (no `enum`, no parameter properties).
|
|
1226
|
+
*/
|
|
1227
|
+
var NODE_ENGINES = { node: ">=22.18" };
|
|
552
1228
|
/**
|
|
553
1229
|
* What `start` means, which is the whole difference between the two modes.
|
|
554
1230
|
*
|
|
555
1231
|
* `client` runs Vite, so a save is a hot module replacement — the page updates without reloading, and editing the
|
|
556
|
-
* space is a live loop. `server` runs the page server under
|
|
1232
|
+
* space is a live loop. `server` runs the page server under Node's `--watch`: there is no client bundle of this
|
|
557
1233
|
* project's own to hot-replace (the SDK is served by the server from its own copy), so a save restarts the
|
|
558
1234
|
* process and the next request renders the change. Both are one command; only one of them is HMR, and calling
|
|
559
1235
|
* the other one HMR would be a promise the loop does not keep.
|
|
560
1236
|
*/
|
|
561
1237
|
var scripts = ({ mode, source }) => ({
|
|
562
1238
|
...mode === "server" ? {
|
|
563
|
-
start: "node
|
|
1239
|
+
start: "node src/main.ts",
|
|
564
1240
|
/**
|
|
565
1241
|
* Watched by PATH, not wholesale.
|
|
566
1242
|
*
|
|
567
1243
|
* The server compiles the project's plugins into `.sdk-plugins/` and then IMPORTS what it built, so a
|
|
568
1244
|
* bare `--watch` sees its own output land, restarts, compiles again, and never stops.
|
|
569
1245
|
*/
|
|
570
|
-
"start:dev": "node --
|
|
1246
|
+
"start:dev": "node --watch-path=./src src/main.ts",
|
|
1247
|
+
/**
|
|
1248
|
+
* What production runs: the same entry compiled to JavaScript. Node strips types by loading a TypeScript
|
|
1249
|
+
* transformer into the process — ~10 MB a server keeps for its whole life to read one file — so a deployment
|
|
1250
|
+
* runs what `build` emitted and carries no TypeScript at all.
|
|
1251
|
+
*/
|
|
1252
|
+
build: "tsc -p tsconfig.build.json",
|
|
1253
|
+
"start:prod": "node dist/main.js"
|
|
571
1254
|
} : {
|
|
572
1255
|
start: "vite",
|
|
573
1256
|
build: "vite build",
|
|
574
1257
|
preview: "vite preview"
|
|
575
1258
|
},
|
|
576
|
-
...source === "local" ? { author: "node
|
|
1259
|
+
...source === "local" ? { author: "node src/author.ts" } : {},
|
|
577
1260
|
typecheck: "tsc -p tsconfig.json --noEmit",
|
|
578
1261
|
lint: "eslint .",
|
|
579
1262
|
format: "prettier --write .",
|
|
580
|
-
visual: "playwright test"
|
|
1263
|
+
visual: "playwright test",
|
|
1264
|
+
shot: "node scripts/shot.ts"
|
|
581
1265
|
});
|
|
582
|
-
var packageJson = (answers) => `${JSON.stringify({
|
|
1266
|
+
var packageJson$1 = (answers) => `${JSON.stringify({
|
|
583
1267
|
name: answers.name,
|
|
584
1268
|
version: "0.0.0",
|
|
585
1269
|
private: true,
|
|
@@ -587,9 +1271,25 @@ var packageJson = (answers) => `${JSON.stringify({
|
|
|
587
1271
|
scripts: scripts(answers),
|
|
588
1272
|
dependencies: dependencies(answers),
|
|
589
1273
|
devDependencies: devDependencies(answers),
|
|
1274
|
+
engines: NODE_ENGINES,
|
|
590
1275
|
...managerPackageFields(answers.packageManager)
|
|
591
1276
|
}, null, 2)}\n`;
|
|
592
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* What `build` compiles: the server's own code, to \`dist/\`, with each \`./space.ts\` import rewritten to the \`.js\` it
|
|
1279
|
+
* becomes. The plugins are left out — the page server builds those itself, from their source.
|
|
1280
|
+
*/
|
|
1281
|
+
var tsconfigBuild = () => `${JSON.stringify({
|
|
1282
|
+
extends: "./tsconfig.json",
|
|
1283
|
+
compilerOptions: {
|
|
1284
|
+
noEmit: false,
|
|
1285
|
+
outDir: "dist",
|
|
1286
|
+
rootDir: "src",
|
|
1287
|
+
rewriteRelativeImportExtensions: true
|
|
1288
|
+
},
|
|
1289
|
+
include: ["src"],
|
|
1290
|
+
exclude: ["src/plugins"]
|
|
1291
|
+
}, null, 2)}\n`;
|
|
1292
|
+
var tsconfig$1 = ({ mode }) => `${JSON.stringify({
|
|
593
1293
|
compilerOptions: {
|
|
594
1294
|
target: "ES2023",
|
|
595
1295
|
module: "esnext",
|
|
@@ -599,6 +1299,9 @@ var tsconfig = ({ mode }) => `${JSON.stringify({
|
|
|
599
1299
|
esModuleInterop: true,
|
|
600
1300
|
skipLibCheck: true,
|
|
601
1301
|
resolveJsonModule: true,
|
|
1302
|
+
allowImportingTsExtensions: true,
|
|
1303
|
+
verbatimModuleSyntax: true,
|
|
1304
|
+
erasableSyntaxOnly: true,
|
|
602
1305
|
/**
|
|
603
1306
|
* `vite/client` in the browser build, and it is not optional there: it is what declares a side-effect CSS
|
|
604
1307
|
* import and `import.meta.env`, both of which the entry point uses.
|
|
@@ -613,6 +1316,7 @@ var tsconfig = ({ mode }) => `${JSON.stringify({
|
|
|
613
1316
|
},
|
|
614
1317
|
include: [
|
|
615
1318
|
"src",
|
|
1319
|
+
"scripts",
|
|
616
1320
|
"visual",
|
|
617
1321
|
"playwright.config.ts",
|
|
618
1322
|
...mode === "client" ? ["vite.config.ts"] : []
|
|
@@ -622,11 +1326,11 @@ var tsconfig = ({ mode }) => `${JSON.stringify({
|
|
|
622
1326
|
* Yarn's four lines are Yarn's own recommendation, and they are not decoration: with the `node-modules` linker it
|
|
623
1327
|
* writes `.yarn/install-state.gz` into the project, which is a cache and belongs in no repository.
|
|
624
1328
|
*/
|
|
625
|
-
var YARN_IGNORES = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n";
|
|
1329
|
+
var YARN_IGNORES$1 = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n";
|
|
626
1330
|
/** Where the page server writes the plugin bundles it builds. A build output, and rebuilt whenever it is missing. */
|
|
627
1331
|
var SERVER_IGNORES = ".sdk-plugins\n";
|
|
628
|
-
var gitignore = ({ mode, packageManager }) => `node_modules\ndist\n.env\nvisual/.results\nvisual/screenshots\n${mode === "server" ? SERVER_IGNORES : ""}${packageManager === "yarn" ? YARN_IGNORES : ""}`;
|
|
629
|
-
var startLine = ({ mode, packageManager }) => mode === "server" ? `\`${runCommand(packageManager, "start")}\` serves pages on http://127.0.0.1:8080. \`${runCommand(packageManager, "start:dev")}\` restarts on save.` : `\`${runCommand(packageManager, "start")}\` runs Vite on http://127.0.0.1:5173, with hot module replacement.`;
|
|
1332
|
+
var gitignore$1 = ({ mode, packageManager }) => `node_modules\ndist\n.env\nvisual/.results\nvisual/screenshots\n${mode === "server" ? SERVER_IGNORES : ""}${packageManager === "yarn" ? YARN_IGNORES$1 : ""}`;
|
|
1333
|
+
var startLine = ({ mode, packageManager }) => mode === "server" ? `\`${runCommand(packageManager, "start")}\` serves pages on http://127.0.0.1:8080. \`${runCommand(packageManager, "start:dev")}\` restarts on save. In production, \`${runCommand(packageManager, "build")}\` once and run \`${runCommand(packageManager, "start:prod")}\` with \`NODE_ENV=production\`: the compiled server, with no TypeScript in the process.` : `\`${runCommand(packageManager, "start")}\` runs Vite on http://127.0.0.1:5173, with hot module replacement.`;
|
|
630
1334
|
var spaceSection = (answers) => {
|
|
631
1335
|
if (answers.source === "cloud") return `## Where the space comes from
|
|
632
1336
|
|
|
@@ -649,7 +1353,7 @@ Nothing is fetched and nothing is signed in to: there is no account, no key and
|
|
|
649
1353
|
somewhere else — imported into Plitzi, handed to another server, or checked into a repository with no TypeScript
|
|
650
1354
|
in it. Nothing here reads that file; the declaration is the source.`;
|
|
651
1355
|
};
|
|
652
|
-
var readme = (answers) => `# ${answers.name}
|
|
1356
|
+
var readme$1 = (answers) => `# ${answers.name}
|
|
653
1357
|
|
|
654
1358
|
A Plitzi space, rendered ${answers.mode === "server" ? "by a server of your own (SSR + RSC)" : "in the browser, with no server at all"}.
|
|
655
1359
|
|
|
@@ -666,7 +1370,8 @@ ${spaceSection(answers)}
|
|
|
666
1370
|
## The skills
|
|
667
1371
|
|
|
668
1372
|
\`.claude/skills/\` carries Plitzi's authoring skill, so an agent working in this repository knows how a space is
|
|
669
|
-
put together before it touches one
|
|
1373
|
+
put together before it touches one, and the CLI's, so it knows what \`plitzi\` can do for it — plugins, packing,
|
|
1374
|
+
uploading. Claude Code reads them automatically; \`AGENTS.md\` points any other agent at them.
|
|
670
1375
|
`;
|
|
671
1376
|
/**
|
|
672
1377
|
* What any agent opening the project reads first, whichever agent it is.
|
|
@@ -702,6 +1407,8 @@ ${commands.join("\n")}
|
|
|
702
1407
|
|
|
703
1408
|
Read ${code(".claude/skills/plitzi-authoring/SKILL.md")} — how a space is written, and the references it links to for
|
|
704
1409
|
layouts, data, templates and flows. The types of ${code("@plitzi/sdk-authoring")} document every factory and field.
|
|
1410
|
+
For a component of your own — a plugin — or anything about packing or uploading one, read
|
|
1411
|
+
${code(".claude/skills/plitzi-cli/SKILL.md")} first: ${code("plitzi add plugin")} writes it in the shape everything reads.
|
|
705
1412
|
|
|
706
1413
|
## The rules that go wrong most
|
|
707
1414
|
|
|
@@ -740,10 +1447,11 @@ VITE_PLITZI_ENVIRONMENT=${environment}
|
|
|
740
1447
|
`;
|
|
741
1448
|
var projectFiles = (answers) => ({
|
|
742
1449
|
...managerFiles(answers.packageManager, answers.managerVersion),
|
|
743
|
-
"package.json": packageJson(answers),
|
|
744
|
-
"tsconfig.json": tsconfig(answers),
|
|
745
|
-
".
|
|
746
|
-
"
|
|
1450
|
+
"package.json": packageJson$1(answers),
|
|
1451
|
+
"tsconfig.json": tsconfig$1(answers),
|
|
1452
|
+
...answers.mode === "server" ? { "tsconfig.build.json": tsconfigBuild() } : {},
|
|
1453
|
+
".gitignore": gitignore$1(answers),
|
|
1454
|
+
"README.md": readme$1(answers),
|
|
747
1455
|
"AGENTS.md": agentsFile(answers),
|
|
748
1456
|
"CLAUDE.md": "@AGENTS.md\n",
|
|
749
1457
|
...answers.source === "cloud" ? { ".env": envFile(answers) } : {}
|
|
@@ -771,7 +1479,15 @@ var prettierrc = () => `${JSON.stringify({
|
|
|
771
1479
|
arrowParens: "avoid",
|
|
772
1480
|
endOfLine: "lf"
|
|
773
1481
|
}, null, 2)}\n`;
|
|
774
|
-
|
|
1482
|
+
/** What a project writes that is nobody's to format or lint: its builds, its authored documents, its test output. */
|
|
1483
|
+
var PROJECT_OUTPUTS = [
|
|
1484
|
+
"dist",
|
|
1485
|
+
"space",
|
|
1486
|
+
".sdk-plugins",
|
|
1487
|
+
"visual/.results",
|
|
1488
|
+
"visual/screenshots"
|
|
1489
|
+
];
|
|
1490
|
+
var prettierignore = (outputs) => `${["node_modules", ...outputs.filter((output) => output !== ".sdk-plugins")].join("\n")}\n`;
|
|
775
1491
|
/**
|
|
776
1492
|
* Flat config, and only what earns its place.
|
|
777
1493
|
*
|
|
@@ -780,14 +1496,14 @@ var prettierignore = () => "node_modules\ndist\nspace\nvisual/.results\nvisual/s
|
|
|
780
1496
|
* that no amount of formatting would have shown. `eslintConfigPrettier` goes last, so it can switch off the
|
|
781
1497
|
* stylistic rules the earlier entries turned on.
|
|
782
1498
|
*/
|
|
783
|
-
var eslintConfig = (
|
|
1499
|
+
var eslintConfig = (globals, outputs) => `import js from '@eslint/js';
|
|
784
1500
|
import reactHooks from 'eslint-plugin-react-hooks';
|
|
785
1501
|
import globals from 'globals';
|
|
786
1502
|
import tsEslint from 'typescript-eslint';
|
|
787
1503
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
|
788
1504
|
|
|
789
1505
|
export default tsEslint.config(
|
|
790
|
-
{ ignores: [
|
|
1506
|
+
{ ignores: [${outputs.map((output) => `'${output}'`).join(", ")}] },
|
|
791
1507
|
js.configs.recommended,
|
|
792
1508
|
{
|
|
793
1509
|
/**
|
|
@@ -800,7 +1516,7 @@ export default tsEslint.config(
|
|
|
800
1516
|
files: ['**/*.{ts,tsx}'],
|
|
801
1517
|
extends: [tsEslint.configs.recommendedTypeChecked],
|
|
802
1518
|
languageOptions: {
|
|
803
|
-
globals: { ...globals.${
|
|
1519
|
+
globals: { ...globals.${globals} },
|
|
804
1520
|
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }
|
|
805
1521
|
}
|
|
806
1522
|
},
|
|
@@ -819,11 +1535,13 @@ export default tsEslint.config(
|
|
|
819
1535
|
eslintConfigPrettier
|
|
820
1536
|
);
|
|
821
1537
|
`;
|
|
822
|
-
|
|
1538
|
+
/** The three files for code that runs where `globals` says, with `outputs` left alone. */
|
|
1539
|
+
var qualityFilesFor = (globals, outputs) => ({
|
|
823
1540
|
".prettierrc": prettierrc(),
|
|
824
|
-
".prettierignore": prettierignore(),
|
|
825
|
-
"eslint.config.mjs": eslintConfig(
|
|
1541
|
+
".prettierignore": prettierignore(outputs),
|
|
1542
|
+
"eslint.config.mjs": eslintConfig(globals, outputs)
|
|
826
1543
|
});
|
|
1544
|
+
var qualityFiles = ({ mode }) => qualityFilesFor(mode === "server" ? "node" : "browser", PROJECT_OUTPUTS);
|
|
827
1545
|
//#endregion
|
|
828
1546
|
//#region src/scaffold/server.ts
|
|
829
1547
|
/**
|
|
@@ -834,20 +1552,27 @@ var qualityFiles = (answers) => ({
|
|
|
834
1552
|
*/
|
|
835
1553
|
/** Written into both entry points, because how a plugin is registered does not change with where the space lives. */
|
|
836
1554
|
var PLUGINS = `/**
|
|
837
|
-
* The project's own components
|
|
1555
|
+
* The project's own components: every folder of \`src/plugins\` is one, registered under its name in camelCase —
|
|
1556
|
+
* \`src/plugins/StatCard\` is what a space's \`custom({ renderType: 'statCard' })\` renders. \`plitzi add plugin\` writes
|
|
1557
|
+
* a new one there; the next start registers it.
|
|
838
1558
|
*
|
|
839
1559
|
* \`action: 'compile'\` is what makes them SERVER-rendered. The server builds the entry with esbuild, keeps React
|
|
840
1560
|
* external so the plugin runs on the one copy this page already has, serves the bundle to the browser AND imports
|
|
841
|
-
* it into the render — so the component's markup is in the HTML before any JavaScript arrives.
|
|
842
|
-
*
|
|
1561
|
+
* it into the render — so the component's markup is in the HTML before any JavaScript arrives. See
|
|
1562
|
+
* \`src/plugins/README.md\`.
|
|
843
1563
|
*/
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
|
|
1564
|
+
// From the project root, so the path holds whether this file runs as \`src/main.ts\` or compiled as \`dist/main.js\`.
|
|
1565
|
+
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
|
|
1566
|
+
const PLUGINS_DIR = path.join(PROJECT_ROOT, 'src/plugins');
|
|
1567
|
+
|
|
1568
|
+
const plugins = Object.fromEntries(
|
|
1569
|
+
readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
|
1570
|
+
.filter(entry => entry.isDirectory())
|
|
1571
|
+
.map(entry => [
|
|
1572
|
+
\`\${entry.name.charAt(0).toLowerCase()}\${entry.name.slice(1)}\`,
|
|
1573
|
+
{ js: path.join(PLUGINS_DIR, entry.name, 'index.ts'), action: 'compile' as const, version: '1.0.0' }
|
|
1574
|
+
])
|
|
1575
|
+
);
|
|
851
1576
|
|
|
852
1577
|
/**
|
|
853
1578
|
* Registering a plugin is not the same as turning it on.
|
|
@@ -858,15 +1583,19 @@ const plugins = {
|
|
|
858
1583
|
* no error anywhere, which is a long afternoon.
|
|
859
1584
|
*/
|
|
860
1585
|
const pluginNames = Object.keys(plugins);`;
|
|
861
|
-
var localMain = () => `import
|
|
1586
|
+
var localMain = () => `import { readdirSync } from 'node:fs';
|
|
1587
|
+
import path from 'node:path';
|
|
862
1588
|
|
|
863
|
-
import { closeOnSignals, consoleLogger, createJsonAdapters, createServer } from '@plitzi/sdk-server';
|
|
1589
|
+
import { closeOnSignals, consoleLogger, createJsonAdapters, createServer, loadFunctions } from '@plitzi/sdk-server';
|
|
864
1590
|
|
|
865
1591
|
import { authorSpace } from '@plitzi/sdk-authoring';
|
|
866
1592
|
|
|
867
|
-
import {
|
|
1593
|
+
import { declarations } from './plugins/declarations.ts';
|
|
1594
|
+
import { space } from './space.ts';
|
|
868
1595
|
|
|
869
1596
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
1597
|
+
// Loopback unless told otherwise: a container publishes a port only from an address it listens on (\`HOST=0.0.0.0\`).
|
|
1598
|
+
const HOST = process.env.HOST ?? '127.0.0.1';
|
|
870
1599
|
|
|
871
1600
|
/**
|
|
872
1601
|
* The space, held in this project.
|
|
@@ -875,7 +1604,8 @@ const PORT = Number(process.env.PORT ?? 8080);
|
|
|
875
1604
|
* boot, so saving that file and letting \`--watch\` restart it is the whole edit loop — and its warnings are printed
|
|
876
1605
|
* here for the same reason: this restart is the output somebody editing the space is actually watching.
|
|
877
1606
|
*/
|
|
878
|
-
|
|
1607
|
+
// \`declarations\`: what the project's plugins fire, answer and read, so the space's use of them is checked too.
|
|
1608
|
+
const { schema, style, warnings } = authorSpace(space, { plugins: declarations });
|
|
879
1609
|
const offlineData = { schema, style };
|
|
880
1610
|
|
|
881
1611
|
for (const warning of warnings) {
|
|
@@ -884,6 +1614,13 @@ for (const warning of warnings) {
|
|
|
884
1614
|
|
|
885
1615
|
${PLUGINS}
|
|
886
1616
|
|
|
1617
|
+
/**
|
|
1618
|
+
* This project's own server code: \`functions/\` — what \`plitzi functions pull\` writes and \`push\` sends — built
|
|
1619
|
+
* the way Plitzi builds a space's and run here, in this process. Nothing there, no functions; code that does not build
|
|
1620
|
+
* stops the server with the file and line.
|
|
1621
|
+
*/
|
|
1622
|
+
const functions = await loadFunctions(new URL('../functions/', import.meta.url));
|
|
1623
|
+
|
|
887
1624
|
/**
|
|
888
1625
|
* Where the server gets a space from, and the only line that knows.
|
|
889
1626
|
*
|
|
@@ -899,10 +1636,11 @@ const server = createServer({
|
|
|
899
1636
|
deployment: { spaceId: 1, environment: 'main', revision: 0, pluginNames }
|
|
900
1637
|
}),
|
|
901
1638
|
plugins,
|
|
1639
|
+
functions: { native: functions },
|
|
902
1640
|
logger: consoleLogger
|
|
903
1641
|
});
|
|
904
1642
|
|
|
905
|
-
server.listen(PORT,
|
|
1643
|
+
server.listen(PORT, HOST);
|
|
906
1644
|
console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
|
|
907
1645
|
|
|
908
1646
|
/**
|
|
@@ -912,11 +1650,14 @@ console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
|
|
|
912
1650
|
*/
|
|
913
1651
|
closeOnSignals(server);
|
|
914
1652
|
`;
|
|
915
|
-
var cloudMain = () => `import
|
|
1653
|
+
var cloudMain = () => `import { readdirSync } from 'node:fs';
|
|
1654
|
+
import path from 'node:path';
|
|
916
1655
|
|
|
917
|
-
import { closeOnSignals, consoleLogger, createCloudAdapters, createServer } from '@plitzi/sdk-server';
|
|
1656
|
+
import { closeOnSignals, consoleLogger, createCloudAdapters, createServer, loadFunctions } from '@plitzi/sdk-server';
|
|
918
1657
|
|
|
919
1658
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
1659
|
+
// Loopback unless told otherwise: a container publishes a port only from an address it listens on (\`HOST=0.0.0.0\`).
|
|
1660
|
+
const HOST = process.env.HOST ?? '127.0.0.1';
|
|
920
1661
|
|
|
921
1662
|
${PLUGINS}
|
|
922
1663
|
|
|
@@ -934,6 +1675,13 @@ if (!HOST_KEY) {
|
|
|
934
1675
|
throw new Error('Set PLITZI_HOST_KEY in .env — Credentials, in the builder.');
|
|
935
1676
|
}
|
|
936
1677
|
|
|
1678
|
+
/**
|
|
1679
|
+
* This project's own server code: \`functions/\` — what \`plitzi functions pull\` writes and \`push\` sends — built
|
|
1680
|
+
* the way Plitzi builds a space's and run here, in this process. Nothing there, no functions; code that does not build
|
|
1681
|
+
* stops the server with the file and line.
|
|
1682
|
+
*/
|
|
1683
|
+
const functions = await loadFunctions(new URL('../functions/', import.meta.url));
|
|
1684
|
+
|
|
937
1685
|
/**
|
|
938
1686
|
* The space stays in Plitzi; the SERVER is this one.
|
|
939
1687
|
*
|
|
@@ -956,10 +1704,11 @@ const server = createServer({
|
|
|
956
1704
|
deployment: { pluginNames }
|
|
957
1705
|
}),
|
|
958
1706
|
plugins,
|
|
1707
|
+
functions: { native: functions },
|
|
959
1708
|
logger: consoleLogger
|
|
960
1709
|
});
|
|
961
1710
|
|
|
962
|
-
server.listen(PORT,
|
|
1711
|
+
server.listen(PORT, HOST);
|
|
963
1712
|
console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
|
|
964
1713
|
|
|
965
1714
|
/**
|
|
@@ -980,15 +1729,16 @@ var serverFiles = (answers) => ({ "src/main.ts": answers.source === "cloud" ? cl
|
|
|
980
1729
|
* manager. Read from the installed package rather than embedded here, so the CLI cannot ship a stale copy of
|
|
981
1730
|
* somebody else's documentation.
|
|
982
1731
|
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
1732
|
+
* Two travel: `plitzi-authoring` (how a space is written) and `plitzi-cli` (what this command line does — plugins,
|
|
1733
|
+
* packing, uploading — so an agent reaches for it instead of hand-writing what it generates). `plitzi-render` is the
|
|
1734
|
+
* other one Plitzi ships, and it is deliberately left out: it drives the `plitzi_render` MCP tool, so in a project with
|
|
1735
|
+
* no MCP connection it would be an instruction to use something that is not there.
|
|
986
1736
|
*/
|
|
987
1737
|
var require = createRequire(import.meta.url);
|
|
988
|
-
var SKILLS =
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
1738
|
+
var SKILLS = {
|
|
1739
|
+
"plitzi-authoring": "@plitzi/sdk-authoring",
|
|
1740
|
+
"plitzi-cli": "@plitzi/cli"
|
|
1741
|
+
};
|
|
992
1742
|
/**
|
|
993
1743
|
* Where a skill file sits, resolved through the package's own export map rather than guessed from a path.
|
|
994
1744
|
*
|
|
@@ -1009,11 +1759,12 @@ var filesUnder = (root, folder = root) => readdirSync(folder, { withFileTypes: t
|
|
|
1009
1759
|
if (entry.isDirectory()) return filesUnder(root, path);
|
|
1010
1760
|
return entry.isFile() ? [[relative(root, path).split(sep).join("/"), readFileSync(path, "utf-8")]] : [];
|
|
1011
1761
|
});
|
|
1012
|
-
|
|
1762
|
+
/** The skills a project carries: both for a project that renders a space, the CLI's alone for a plugin package. */
|
|
1763
|
+
var skillFiles = (names = ["plitzi-authoring", "plitzi-cli"]) => {
|
|
1013
1764
|
const files = {};
|
|
1014
|
-
for (const
|
|
1015
|
-
const root = dirname(skillPath(
|
|
1016
|
-
for (const [path, content] of filesUnder(root)) files[`.claude/skills/${
|
|
1765
|
+
for (const name of names) try {
|
|
1766
|
+
const root = dirname(skillPath(SKILLS[name], name));
|
|
1767
|
+
for (const [path, content] of filesUnder(root)) files[`.claude/skills/${name}/${path}`] = content;
|
|
1017
1768
|
} catch {
|
|
1018
1769
|
continue;
|
|
1019
1770
|
}
|
|
@@ -1043,9 +1794,10 @@ var authorScript = () => `import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
1043
1794
|
|
|
1044
1795
|
import { authorSpace } from '@plitzi/sdk-authoring';
|
|
1045
1796
|
|
|
1046
|
-
import {
|
|
1797
|
+
import { declarations } from './plugins/declarations.ts';
|
|
1798
|
+
import { space } from './space.ts';
|
|
1047
1799
|
|
|
1048
|
-
const { schema, style, warnings } = authorSpace(space);
|
|
1800
|
+
const { schema, style, warnings } = authorSpace(space, { plugins: declarations });
|
|
1049
1801
|
|
|
1050
1802
|
mkdirSync('space', { recursive: true });
|
|
1051
1803
|
writeFileSync('space/offline-data.json', \`\${JSON.stringify({ schema, style }, null, 2)}\\n\`);
|
|
@@ -1056,39 +1808,59 @@ for (const warning of warnings) {
|
|
|
1056
1808
|
|
|
1057
1809
|
console.log('space/offline-data.json');
|
|
1058
1810
|
`;
|
|
1811
|
+
/** The numbers the example shows: in a data file where one is served, on the element where none is. */
|
|
1812
|
+
var STATS = {
|
|
1813
|
+
value: 12480,
|
|
1814
|
+
series: [
|
|
1815
|
+
8,
|
|
1816
|
+
12,
|
|
1817
|
+
9,
|
|
1818
|
+
17,
|
|
1819
|
+
14,
|
|
1820
|
+
21,
|
|
1821
|
+
19,
|
|
1822
|
+
26
|
|
1823
|
+
]
|
|
1824
|
+
};
|
|
1059
1825
|
/**
|
|
1060
|
-
* The plugin the copy hosts, and
|
|
1826
|
+
* The plugin the copy hosts, and what it is authored with.
|
|
1061
1827
|
*
|
|
1062
1828
|
* Asked for here rather than defaulted on in the package: the platform authors a new space from the same
|
|
1063
1829
|
* declaration and hosts nobody's plugins, so the slot exists only where a project carries the component to fill
|
|
1064
|
-
* it.
|
|
1065
|
-
*
|
|
1830
|
+
* it. Its attributes are the component's props, by name. A client project serves `public/`, so there the numbers
|
|
1831
|
+
* come from a data file through a provider — the way a project with no backend shows data it did not invent.
|
|
1066
1832
|
*/
|
|
1067
|
-
var
|
|
1833
|
+
var pluginHost = ({ mode }) => mode === "client" ? {
|
|
1068
1834
|
id: "stat-card",
|
|
1069
1835
|
renderType: "statCard",
|
|
1070
|
-
|
|
1836
|
+
attributes: {
|
|
1837
|
+
label: "Requests today",
|
|
1838
|
+
unit: "reqs"
|
|
1839
|
+
},
|
|
1840
|
+
data: {
|
|
1841
|
+
id: "stats",
|
|
1842
|
+
query: "/data/stats.json",
|
|
1843
|
+
bind: {
|
|
1844
|
+
value: "stats.data.value",
|
|
1845
|
+
series: "stats.data.series"
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
} : {
|
|
1849
|
+
id: "stat-card",
|
|
1850
|
+
renderType: "statCard",
|
|
1851
|
+
attributes: {
|
|
1071
1852
|
label: "Requests today",
|
|
1072
|
-
value: 12480,
|
|
1073
1853
|
unit: "reqs",
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
17,
|
|
1079
|
-
14,
|
|
1080
|
-
21,
|
|
1081
|
-
19,
|
|
1082
|
-
26
|
|
1083
|
-
]
|
|
1084
|
-
}
|
|
1085
|
-
};
|
|
1086
|
-
var spaceFiles = ({ source, name }) => source === "cloud" ? {} : {
|
|
1854
|
+
...STATS
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
var spaceFiles = (answers) => answers.source === "cloud" ? {} : {
|
|
1087
1858
|
"src/space.ts": blankSpaceSource({
|
|
1088
|
-
name,
|
|
1089
|
-
plugin:
|
|
1859
|
+
name: answers.name,
|
|
1860
|
+
plugin: pluginHost(answers)
|
|
1090
1861
|
}),
|
|
1091
|
-
"src/author.ts": authorScript()
|
|
1862
|
+
"src/author.ts": authorScript(),
|
|
1863
|
+
...answers.mode === "client" ? { "public/data/stats.json": `${JSON.stringify(STATS, null, 2)}\n` } : {}
|
|
1092
1864
|
};
|
|
1093
1865
|
//#endregion
|
|
1094
1866
|
//#region src/scaffold/visual.ts
|
|
@@ -1099,7 +1871,7 @@ var spaceFiles = ({ source, name }) => source === "cloud" ? {} : {
|
|
|
1099
1871
|
* a space renders through a stack — schema, style, plugins, hydration — where a mistake shows up as a blank page
|
|
1100
1872
|
* rather than as an exception, and nobody writes the first test for a project that already looks fine.
|
|
1101
1873
|
*/
|
|
1102
|
-
var playwrightConfig = ({ mode, packageManager }) => `import { defineConfig } from '@playwright/test';
|
|
1874
|
+
var playwrightConfig$1 = ({ mode, packageManager }) => `import { defineConfig } from '@playwright/test';
|
|
1103
1875
|
|
|
1104
1876
|
const PORT = ${mode === "server" ? "8080" : "5173"};
|
|
1105
1877
|
|
|
@@ -1119,39 +1891,38 @@ export default defineConfig({
|
|
|
1119
1891
|
`;
|
|
1120
1892
|
var authoredSpec = () => `import { expect, test } from '@playwright/test';
|
|
1121
1893
|
|
|
1122
|
-
import { authorSpace,
|
|
1894
|
+
import { authorSpace, inspectPage } from '@plitzi/sdk-authoring';
|
|
1123
1895
|
|
|
1124
|
-
import {
|
|
1896
|
+
import { declarations } from '../src/plugins/declarations.ts';
|
|
1897
|
+
import { space } from '../src/space.ts';
|
|
1125
1898
|
|
|
1126
1899
|
/**
|
|
1127
|
-
*
|
|
1900
|
+
* Every page renders whole: everything the space NAMES is on screen, images arrived, nothing scrolls sideways, and no
|
|
1901
|
+
* text is drawn in the colour behind it.
|
|
1128
1902
|
*
|
|
1129
1903
|
* The strongest assertion available about a page you did not hand-write, and it costs no upkeep: an id an author
|
|
1130
1904
|
* bothered to write down is an element somebody meant to point at, and \`authorSpace\` reports which those were.
|
|
1131
1905
|
* Rename one and this fails at author time with a suggestion, rather than at test time with an empty locator.
|
|
1132
1906
|
*
|
|
1133
1907
|
* One test per page, so the second page you add is covered the moment it exists. What a bare visit cannot show is
|
|
1134
|
-
* left to tests of its own: a page behind a session or with a route param (\`post/{{slug}}\`)
|
|
1135
|
-
*
|
|
1908
|
+
* left to tests of its own: a page behind a session or with a route param (\`post/{{slug}}\`). What shows only under a
|
|
1909
|
+
* condition, renders once per list row or has no box of its own, \`inspectPage\` sets aside by itself.
|
|
1136
1910
|
*/
|
|
1137
|
-
const { handles } = authorSpace(space);
|
|
1911
|
+
const { handles } = authorSpace(space, { plugins: declarations });
|
|
1138
1912
|
|
|
1139
1913
|
const openable = Object.values(handles.pages).filter(
|
|
1140
1914
|
pageHandle => pageHandle.accessLevel !== 'authenticated' && pageHandle.params.length === 0
|
|
1141
1915
|
);
|
|
1142
1916
|
|
|
1143
1917
|
for (const pageHandle of openable) {
|
|
1144
|
-
test(\`\${pageHandle.path} renders
|
|
1918
|
+
test(\`\${pageHandle.path} renders whole\`, async ({ page }) => {
|
|
1145
1919
|
const errors: string[] = [];
|
|
1146
1920
|
page.on('pageerror', error => errors.push(error.message));
|
|
1147
1921
|
|
|
1148
1922
|
await page.goto(pageHandle.path, { waitUntil: 'networkidle' });
|
|
1149
|
-
const el = locate(page, handles);
|
|
1150
|
-
|
|
1151
|
-
for (const handle of Object.values(pageHandle.elements).filter(entry => entry.named && !entry.conditional)) {
|
|
1152
|
-
await expect(el(handle.id), \`\${handle.type} "\${handle.id}"\`).toBeVisible();
|
|
1153
|
-
}
|
|
1154
1923
|
|
|
1924
|
+
// Every problem at once, each naming the element and why — which ancestor hid it, what overflowed.
|
|
1925
|
+
expect((await inspectPage(page, handles, { page: pageHandle.id })).problems).toEqual([]);
|
|
1155
1926
|
expect(errors).toEqual([]);
|
|
1156
1927
|
});
|
|
1157
1928
|
}
|
|
@@ -1178,121 +1949,2578 @@ test('renders the space without errors', async ({ page }) => {
|
|
|
1178
1949
|
expect(errors).toEqual([]);
|
|
1179
1950
|
});
|
|
1180
1951
|
`;
|
|
1181
|
-
var
|
|
1182
|
-
|
|
1952
|
+
var shotScript = ({ mode }) => `import { mkdir } from 'node:fs/promises';
|
|
1953
|
+
import { dirname } from 'node:path';
|
|
1954
|
+
|
|
1955
|
+
import { chromium } from '@playwright/test';
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* A screenshot of one page, to look at a change without writing a test for it.
|
|
1959
|
+
*
|
|
1960
|
+
* npm run shot -- /about --width 390 --scheme dark
|
|
1961
|
+
* npm run shot -- / --out shots/home.png --height 900
|
|
1962
|
+
*
|
|
1963
|
+
* The whole page, not one screen of it. The dev server has to be running (npm start).
|
|
1964
|
+
*/
|
|
1965
|
+
const PORT = ${mode === "server" ? "8080" : "5173"};
|
|
1966
|
+
|
|
1967
|
+
const args = process.argv.slice(2);
|
|
1968
|
+
const option = (name: string, fallback: string): string => {
|
|
1969
|
+
const index = args.indexOf('--' + name);
|
|
1970
|
+
|
|
1971
|
+
return index === -1 ? fallback : (args[index + 1] ?? fallback);
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
const path = args.find(arg => arg.startsWith('/')) ?? '/';
|
|
1975
|
+
const width = Number(option('width', '1280'));
|
|
1976
|
+
const height = Number(option('height', '800'));
|
|
1977
|
+
const scheme = option('scheme', 'light') === 'dark' ? 'dark' : 'light';
|
|
1978
|
+
const name = path === '/' ? 'home' : path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
|
|
1979
|
+
const out = option('out', 'visual/.shots/' + name + '-' + width + '-' + scheme + '.png');
|
|
1980
|
+
|
|
1981
|
+
const browser = await chromium.launch();
|
|
1982
|
+
try {
|
|
1983
|
+
const page = await browser.newPage({ viewport: { width, height }, colorScheme: scheme });
|
|
1984
|
+
const response = await page.goto('http://127.0.0.1:' + PORT + path, { waitUntil: 'networkidle' }).catch(() => null);
|
|
1985
|
+
if (!response) {
|
|
1986
|
+
throw new Error('Nothing answers on port ' + PORT + '. Start the project first: npm start');
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
await mkdir(dirname(out), { recursive: true });
|
|
1990
|
+
await page.screenshot({ path: out, fullPage: true });
|
|
1991
|
+
console.log(out);
|
|
1992
|
+
} finally {
|
|
1993
|
+
await browser.close();
|
|
1994
|
+
}
|
|
1995
|
+
`;
|
|
1996
|
+
var visualFiles$1 = (answers) => ({
|
|
1997
|
+
"playwright.config.ts": playwrightConfig$1(answers),
|
|
1998
|
+
"scripts/shot.ts": shotScript(answers),
|
|
1183
1999
|
"visual/home.spec.ts": answers.source === "local" ? authoredSpec() : documentSpec()
|
|
1184
2000
|
});
|
|
1185
2001
|
//#endregion
|
|
1186
|
-
//#region src/scaffold/
|
|
2002
|
+
//#region src/scaffold/pluginPackage/build.ts
|
|
1187
2003
|
/**
|
|
1188
|
-
*
|
|
2004
|
+
* The preview's Vite config — and only the preview's.
|
|
1189
2005
|
*
|
|
1190
|
-
*
|
|
2006
|
+
* Building the plugin is not this package's code: `plitzi pack plugin` does it, the one implementation every plugin is
|
|
2007
|
+
* built with, so the platform's contract (React and the SDK kept out, one file, a manifest with each file's hash) moves
|
|
2008
|
+
* with the CLI instead of going stale in a copy here.
|
|
2009
|
+
*/
|
|
2010
|
+
var viteConfig = () => `import { createReadStream } from 'node:fs';
|
|
2011
|
+
import { createRequire } from 'node:module';
|
|
2012
|
+
|
|
2013
|
+
import { defineConfig } from 'vite';
|
|
2014
|
+
|
|
2015
|
+
import type { Plugin } from 'vite';
|
|
2016
|
+
|
|
2017
|
+
const require = createRequire(import.meta.url);
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Serves the dev tools' stylesheet at the path the SDK asks for.
|
|
2021
|
+
*
|
|
2022
|
+
* They render into a shadow root, which cannot see this page's styles, so they fetch a stylesheet of their own from
|
|
2023
|
+
* \`/plitzi-sdk-devtools.css\` — a path that exists on a server serving the SDK's assets and nowhere else. Development
|
|
2024
|
+
* only, and read from \`node_modules\` on each request, so it cannot go stale against the installed SDK.
|
|
2025
|
+
*/
|
|
2026
|
+
const devToolsStylesheet = (): Plugin => ({
|
|
2027
|
+
name: 'plitzi-devtools-stylesheet',
|
|
2028
|
+
apply: 'serve',
|
|
2029
|
+
configureServer(server) {
|
|
2030
|
+
server.middlewares.use('/plitzi-sdk-devtools.css', (_request, response) => {
|
|
2031
|
+
response.setHeader('Content-Type', 'text/css');
|
|
2032
|
+
createReadStream(require.resolve('@plitzi/plitzi-sdk/plitzi-sdk-devtools.css')).pipe(response);
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* \`start\` serves the preview: \`index.html\`, every element inside a space. Packing the plugin is the CLI's
|
|
2039
|
+
* (\`plitzi pack plugin\`).
|
|
2040
|
+
*
|
|
2041
|
+
* The host is pinned because Vite binds \`localhost\` — IPv6 on most machines — while what waits for a dev server,
|
|
2042
|
+
* the visual test included, asks for 127.0.0.1.
|
|
2043
|
+
*/
|
|
2044
|
+
export default defineConfig({
|
|
2045
|
+
plugins: [devToolsStylesheet()],
|
|
2046
|
+
server: { host: '127.0.0.1', port: 5173 }
|
|
2047
|
+
});
|
|
2048
|
+
`;
|
|
2049
|
+
var buildFiles = () => ({ "vite.config.ts": viteConfig() });
|
|
2050
|
+
//#endregion
|
|
2051
|
+
//#region src/scaffold/pluginPackage/names.ts
|
|
2052
|
+
var PREFIX = "plitzi-plugin-";
|
|
2053
|
+
/** npm's own rule for a package name, scope included. */
|
|
2054
|
+
var PACKAGE_NAME = /^(@[a-z0-9][a-z0-9-._~]*\/)?[a-z0-9][a-z0-9-._~]*$/;
|
|
2055
|
+
/** What the name has to leave once the scope and prefix are off: words of letters and digits, starting with a letter. */
|
|
2056
|
+
var BASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
2057
|
+
var baseOf = (packageName) => {
|
|
2058
|
+
const unscoped = packageName.replace(/^@[^/]+\//, "");
|
|
2059
|
+
return unscoped.startsWith(PREFIX) ? unscoped.slice(14) : unscoped;
|
|
2060
|
+
};
|
|
2061
|
+
/** What is wrong with a name as a plugin's, in words that say how to fix it — or `undefined` when nothing is. */
|
|
2062
|
+
var pluginNameProblem = (packageName) => {
|
|
2063
|
+
if (!PACKAGE_NAME.test(packageName)) return `"${packageName}" is not a package name npm accepts: lowercase letters, digits and dashes, optionally under a @scope/.`;
|
|
2064
|
+
if (!BASE.test(baseOf(packageName))) return `"${packageName}" has to name the plugin in words — lowercase letters and digits between single dashes, starting with a letter (seat-picker, chart2) — since the component and the type a space uses are made from it.`;
|
|
2065
|
+
const { type } = pluginNames(packageName);
|
|
2066
|
+
if (elementTypeNames.includes(type)) return `"${packageName}" would make the type "${type}", which is a built-in element: a space could not tell the two apart. Name it after what it does (seat-picker, sales-chart).`;
|
|
2067
|
+
};
|
|
2068
|
+
var words = (base) => base.split("-");
|
|
2069
|
+
var capitalised = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
|
|
2070
|
+
/** The names, for a name `pluginNameProblem` accepted. */
|
|
2071
|
+
var pluginNames = (packageName) => {
|
|
2072
|
+
const base = baseOf(packageName);
|
|
2073
|
+
const [first, ...rest] = words(base);
|
|
2074
|
+
return {
|
|
2075
|
+
packageName,
|
|
2076
|
+
base,
|
|
2077
|
+
type: `${first}${rest.map(capitalised).join("")}`,
|
|
2078
|
+
component: words(base).map(capitalised).join(""),
|
|
2079
|
+
title: words(base).map(capitalised).join(" ")
|
|
2080
|
+
};
|
|
2081
|
+
};
|
|
2082
|
+
//#endregion
|
|
2083
|
+
//#region src/scaffold/pluginPackage/docs.ts
|
|
2084
|
+
var readme = (elements, { packageName, packageManager }) => {
|
|
2085
|
+
const run = (script) => runCommand(packageManager, script);
|
|
2086
|
+
const { base } = pluginNames(packageName);
|
|
2087
|
+
const [{ type, component }] = elements;
|
|
2088
|
+
const held = elements.map((element) => `\`${element.type}\``).join(", ");
|
|
2089
|
+
return `# ${packageName}
|
|
2090
|
+
|
|
2091
|
+
A Plitzi plugin: ${elements.length === 1 ? "an element" : "elements"} of your own that any space can render — ${held}.
|
|
2092
|
+
An element of that type renders the component in its folder under \`src/\`, and its attributes arrive as the
|
|
2093
|
+
component's props.
|
|
2094
|
+
|
|
2095
|
+
\`\`\`bash
|
|
2096
|
+
${installCommand(packageManager)}
|
|
2097
|
+
${run("start")} # the plugin inside a space, with hot module replacement
|
|
2098
|
+
${run("visual")} # a browser opens the preview and checks the plugin works
|
|
2099
|
+
npx @plitzi/cli pack plugin # the plugin, built — and zipped the way the builder takes it
|
|
2100
|
+
\`\`\`
|
|
2101
|
+
|
|
2102
|
+
## What is where
|
|
2103
|
+
|
|
2104
|
+
Each element is written the way Plitzi's own elements are (\`@plitzi/sdk-elements\`): one folder, four files.
|
|
2105
|
+
|
|
2106
|
+
- \`src/${component}/${component}.tsx\` — the component: what a page renders.
|
|
2107
|
+
- \`src/${component}/declaration.ts\` — the element as the platform knows it: its \`type\`, the events it fires and the
|
|
2108
|
+
actions it answers to, its starting attributes, how the catalogue shows it and its default style. Data only; the build
|
|
2109
|
+
writes it into the manifest.
|
|
2110
|
+
- \`src/${component}/Settings.tsx\` — its panel in the builder.
|
|
2111
|
+
- \`src/${component}/index.ts\` — the three put together: what a space loads.
|
|
2112
|
+
- \`src/elements.ts\` and \`src/declarations.ts\` — every element the package holds, the one it is named after first:
|
|
2113
|
+
\`src/index.ts\` publishes the first as the plugin and the rest as its \`plugins\`, and the build writes each
|
|
2114
|
+
declaration into the manifest.
|
|
2115
|
+
- \`preview/\` — a space to look at the plugin in, rendered in the browser with no server.
|
|
2116
|
+
|
|
2117
|
+
## Publishing
|
|
2118
|
+
|
|
2119
|
+
\`npx @plitzi/cli pack plugin\` builds the plugin — the CLI is the one place a plugin is packed, so this package carries
|
|
2120
|
+
no build of its own. It writes \`dist/\`: \`${base}.mjs\`, \`plugin-manifest.json\` — which the builder, the page server and
|
|
2121
|
+
the MCP server read to know the plugin before they load it — and \`types/\`, the declarations a project installing the
|
|
2122
|
+
package reads. Beside it, \`${base}-<version>.zip\`.
|
|
2123
|
+
|
|
2124
|
+
- **Through the builder.** Upload the zip under **Resources**, as a plugin: the platform unpacks it at an address of its
|
|
2125
|
+
own and the space can use it.
|
|
2126
|
+
- **From your own host.** Serve \`dist/\` at an address that never changes for a given version
|
|
2127
|
+
(\`https://cdn.example.com/${base}/0.1.0\`), with CORS open to plain reads, and list it in the space's plugins:
|
|
2128
|
+
\`{ type: '${type}', resource: '<that address>' }\`.
|
|
2129
|
+
- **On npm.** Pack first, then publish: \`exports\` points at \`dist/\`.
|
|
2130
|
+
|
|
2131
|
+
## In a project of your own
|
|
2132
|
+
|
|
2133
|
+
- **In the browser** — \`render()\`, or \`<PlitziSdk>\` in a React application — install the package and register every
|
|
2134
|
+
element it holds:
|
|
2135
|
+
|
|
2136
|
+
\`\`\`ts
|
|
2137
|
+
import { elements } from '${packageName}';
|
|
2138
|
+
|
|
2139
|
+
render('root', options, Object.fromEntries(elements.map(element => [element.type, { component: element }])));
|
|
2140
|
+
\`\`\`
|
|
2141
|
+
|
|
2142
|
+
- **On a page server of your own**, compile each element from its source, which is what renders it on the server:
|
|
2143
|
+
\`plugins: { ${type}: { js: path.resolve('node_modules/${packageName}/src/${component}/index.ts'), action: 'compile' } }\`,
|
|
2144
|
+
one entry per element, each named in the deployment's \`pluginNames\`.
|
|
2145
|
+
|
|
2146
|
+
## The contract
|
|
2147
|
+
|
|
2148
|
+
- **React and the SDK are the page's.** The build keeps \`react\`, \`react-dom\` and \`@plitzi/plitzi-sdk\` out of the
|
|
2149
|
+
bundle and imports the page's copies — a second React is "Invalid hook call" and a blank element. Anything else you
|
|
2150
|
+
import is bundled.
|
|
2151
|
+
- **One file.** A page imports the plugin from a blob URL, where a second chunk has nowhere to be found, so the build
|
|
2152
|
+
never splits it. Import freely; it all ends up in \`${base}.mjs\`.
|
|
2153
|
+
- **Render \`RootElement\`, not a \`div\`.** It is what makes the plugin an _element_: the id and classes the space gave
|
|
2154
|
+
it land on what you render, so the CSS authored on it applies, the builder can select it, a test can find it, and
|
|
2155
|
+
its events and actions are its own.
|
|
2156
|
+
- **Nothing that differs between a server and the first render in the browser** — a clock, a random number, anything
|
|
2157
|
+
read out of \`window\`. React answers a hydration mismatch by discarding the whole tree: it does not break the plugin,
|
|
2158
|
+
it blanks the page. Put live values in an effect.
|
|
2159
|
+
- **Do not name colours.** The page has a palette and a light/dark theme; use \`currentColor\` and the space's own
|
|
2160
|
+
\`var(--…)\` variables.
|
|
2161
|
+
|
|
2162
|
+
## Another element
|
|
2163
|
+
|
|
2164
|
+
\`\`\`bash
|
|
2165
|
+
npx @plitzi/cli add plugin legend
|
|
2166
|
+
\`\`\`
|
|
2167
|
+
|
|
2168
|
+
Run in this package, it writes \`src/Legend/\` and lists it in \`src/elements.ts\` and \`src/declarations.ts\` — the
|
|
2169
|
+
package publishes it from then on, beside \`${type}\`. Put an \`element('legend', { id: 'legend' })\` in
|
|
2170
|
+
\`preview/space.ts\` to look at it.
|
|
2171
|
+
`;
|
|
2172
|
+
};
|
|
2173
|
+
var agents = (elements, { packageName, packageManager }) => {
|
|
2174
|
+
const run = (script) => `\`${runCommand(packageManager, script)}\``;
|
|
2175
|
+
return `# ${packageName} — notes for agents
|
|
2176
|
+
|
|
2177
|
+
A Plitzi plugin holding ${elements.map((element) => `\`${element.type}\` (\`src/${element.component}\`)`).join(", ")}, each written
|
|
2178
|
+
the way \`@plitzi/sdk-elements\` writes its own. An element's \`declaration.ts\` says how the platform knows it.
|
|
2179
|
+
|
|
2180
|
+
- ${run("start")} — the preview: the plugin inside a space.
|
|
2181
|
+
- ${run("visual")} — a browser checks the preview.
|
|
2182
|
+
- ${run("typecheck")} and ${run("lint")} — before calling a change done.
|
|
2183
|
+
- \`npx @plitzi/cli pack plugin\` — \`dist/\` (the module, \`plugin-manifest.json\`, the types) and the zip for the builder.
|
|
2184
|
+
- \`npx @plitzi/cli add plugin <name>\` — another element, in the same shape; \`upload plugin\` puts the zip on a space.
|
|
2185
|
+
Everything the CLI does, and how it behaves with nobody at the terminal: \`.claude/skills/plitzi-cli/SKILL.md\`.
|
|
2186
|
+
|
|
2187
|
+
- The component's props are the element's attributes. A new one goes in three places: the props, the declaration
|
|
2188
|
+
(\`content.attributes\`, and \`bindingsAllowed\` if data may drive it), and \`Settings.tsx\`.
|
|
2189
|
+
- A new event or action is declared in \`declaration.ts\` (\`triggers\`, \`callbacks\`) and registered by the component
|
|
2190
|
+
from there — never only in the component.
|
|
2191
|
+
- Render through \`RootElement\`; never render anything on the first pass that differs between a server and a browser.
|
|
2192
|
+
- Never bundle a second React or SDK: the page provides both — see the README's contract.
|
|
2193
|
+
- \`type\` in the declaration is what every space using the plugin names; renaming it orphans those elements.
|
|
2194
|
+
`;
|
|
2195
|
+
};
|
|
2196
|
+
var docsFiles = (elements, answers) => ({
|
|
2197
|
+
"README.md": readme(elements, answers),
|
|
2198
|
+
"AGENTS.md": agents(elements, answers),
|
|
2199
|
+
"CLAUDE.md": "@AGENTS.md\n"
|
|
2200
|
+
});
|
|
2201
|
+
//#endregion
|
|
2202
|
+
//#region src/scaffold/pluginPackage/package.ts
|
|
2203
|
+
/**
|
|
2204
|
+
* The package itself: what it installs, what it publishes and how it is checked.
|
|
2205
|
+
*
|
|
2206
|
+
* React and the SDK are PEER dependencies, which is the package-manager spelling of the build's `external`: the page
|
|
2207
|
+
* provides them and the plugin uses those, so a project installing the plugin never gets a second copy to fight with.
|
|
2208
|
+
* They are dev dependencies too, because the preview renders a page of its own.
|
|
2209
|
+
*
|
|
2210
|
+
* It builds nothing itself. `plitzi pack plugin` is the one place a plugin is packed — into `dist/`, which `exports`
|
|
2211
|
+
* points at, and a zip for the builder — so the package carries no bundler config and no build dependency of its own.
|
|
2212
|
+
*/
|
|
2213
|
+
var packageJson = ({ packageName, base }, { packageManager, owner }) => `${JSON.stringify({
|
|
2214
|
+
name: packageName,
|
|
2215
|
+
version: "0.1.0",
|
|
2216
|
+
license: "MIT",
|
|
2217
|
+
...owner ? { author: owner } : {},
|
|
2218
|
+
type: "module",
|
|
2219
|
+
files: ["dist", "src"],
|
|
2220
|
+
exports: {
|
|
2221
|
+
".": {
|
|
2222
|
+
types: "./dist/types/index.d.ts",
|
|
2223
|
+
import: `./dist/${base}.mjs`
|
|
2224
|
+
},
|
|
2225
|
+
"./plugin-manifest.json": "./dist/plugin-manifest.json"
|
|
2226
|
+
},
|
|
2227
|
+
scripts: {
|
|
2228
|
+
start: "vite",
|
|
2229
|
+
typecheck: "tsc -p tsconfig.json --noEmit",
|
|
2230
|
+
lint: "eslint .",
|
|
2231
|
+
format: "prettier --write .",
|
|
2232
|
+
visual: "playwright test"
|
|
2233
|
+
},
|
|
2234
|
+
peerDependencies: {
|
|
2235
|
+
"@plitzi/plitzi-sdk": SDK_VERSION,
|
|
2236
|
+
react: REACT_VERSION,
|
|
2237
|
+
"react-dom": REACT_VERSION
|
|
2238
|
+
},
|
|
2239
|
+
devDependencies: {
|
|
2240
|
+
...SHARED_DEV_DEPENDENCIES,
|
|
2241
|
+
"@plitzi/plitzi-sdk": SDK_VERSION,
|
|
2242
|
+
"@plitzi/sdk-authoring": SDK_VERSION,
|
|
2243
|
+
react: REACT_VERSION,
|
|
2244
|
+
"react-dom": REACT_VERSION,
|
|
2245
|
+
vite: VITE_VERSION
|
|
2246
|
+
},
|
|
2247
|
+
engines: { node: ">=22.18" },
|
|
2248
|
+
...managerPackageFields(packageManager)
|
|
2249
|
+
}, null, 2)}\n`;
|
|
2250
|
+
/**
|
|
2251
|
+
* Written out rather than serialised: \`JSON.stringify\` puts every array entry on a line of its own, where the project's
|
|
2252
|
+
* own Prettier keeps a short list on one — and a file the formatter rewrites on the first save is noise in the first diff.
|
|
2253
|
+
*/
|
|
2254
|
+
var tsconfig = () => `{
|
|
2255
|
+
"compilerOptions": {
|
|
2256
|
+
"target": "ES2023",
|
|
2257
|
+
"module": "esnext",
|
|
2258
|
+
"moduleResolution": "bundler",
|
|
2259
|
+
"strict": true,
|
|
2260
|
+
"noEmit": true,
|
|
2261
|
+
"esModuleInterop": true,
|
|
2262
|
+
"skipLibCheck": true,
|
|
2263
|
+
"resolveJsonModule": true,
|
|
2264
|
+
"allowImportingTsExtensions": true,
|
|
2265
|
+
"verbatimModuleSyntax": true,
|
|
2266
|
+
"erasableSyntaxOnly": true,
|
|
2267
|
+
"types": ["node", "vite/client"],
|
|
2268
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
2269
|
+
"jsx": "react-jsx"
|
|
2270
|
+
},
|
|
2271
|
+
"include": ["src", "preview", "visual", "vite.config.ts", "playwright.config.ts"]
|
|
2272
|
+
}
|
|
2273
|
+
`;
|
|
2274
|
+
/** Yarn's own recommendation: with the `node-modules` linker it writes a cache into `.yarn/` that no repository wants. */
|
|
2275
|
+
var YARN_IGNORES = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n";
|
|
2276
|
+
var gitignore = ({ packageManager, inProject }) => `node_modules\ndist\n*.zip\nvisual/.results\n${packageManager === "yarn" && !inProject ? YARN_IGNORES : ""}`;
|
|
2277
|
+
var packageFiles = (names, answers) => ({
|
|
2278
|
+
"package.json": packageJson(names, answers),
|
|
2279
|
+
"tsconfig.json": tsconfig(),
|
|
2280
|
+
".gitignore": gitignore(answers)
|
|
2281
|
+
});
|
|
2282
|
+
//#endregion
|
|
2283
|
+
//#region src/scaffold/pluginPackage/quote.ts
|
|
2284
|
+
/**
|
|
2285
|
+
* Free text somebody typed — a title, a description, an owner — written into generated source.
|
|
2286
|
+
*
|
|
2287
|
+
* Interpolated bare, one apostrophe ("Carlos's picker") ends the string it lands in and the generated project does not
|
|
2288
|
+
* parse; these write it the way each kind of file reads it back.
|
|
2289
|
+
*/
|
|
2290
|
+
/**
|
|
2291
|
+
* A TypeScript string literal, quoted the way the generated project's own Prettier would quote it: single quotes,
|
|
2292
|
+
* unless the text holds more of them than of double quotes — so the file needs no reformatting on its first save.
|
|
2293
|
+
*/
|
|
2294
|
+
var tsString = (value) => {
|
|
2295
|
+
const quote = value.split("'").length - 1 > value.split("\"").length - 1 ? "\"" : "'";
|
|
2296
|
+
return `${quote}${value.replace(/\\/g, "\\\\").replaceAll(quote, `\\${quote}`).replace(/\r/g, "\\r").replace(/\n/g, "\\n")}${quote}`;
|
|
2297
|
+
};
|
|
2298
|
+
/** Text inside an HTML element. */
|
|
2299
|
+
var htmlText = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2300
|
+
//#endregion
|
|
2301
|
+
//#region src/scaffold/pluginPackage/preview.ts
|
|
2302
|
+
/**
|
|
2303
|
+
* Where the plugin is looked at while it is written: inside a space, the way a page will show it.
|
|
2304
|
+
*
|
|
2305
|
+
* Not a gallery of the component on its own. What goes wrong with a plugin goes wrong in a page — the theme it did not
|
|
2306
|
+
* expect, the element CSS it did not apply, a flow its event never reached — so the preview is the space a new account
|
|
2307
|
+
* starts with, with the plugin in its hero, rendered by the SDK in the browser with no server at all.
|
|
2308
|
+
*/
|
|
2309
|
+
var indexHtml = ({ title }) => `<!doctype html>
|
|
2310
|
+
<html lang="en">
|
|
2311
|
+
<head>
|
|
2312
|
+
<meta charset="UTF-8" />
|
|
2313
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2314
|
+
<title>${htmlText(title)} — preview</title>
|
|
2315
|
+
</head>
|
|
2316
|
+
<body>
|
|
2317
|
+
<div id="plitzi-root"></div>
|
|
2318
|
+
<script type="module" src="/preview/main.ts"><\/script>
|
|
2319
|
+
</body>
|
|
2320
|
+
</html>
|
|
2321
|
+
`;
|
|
2322
|
+
var preflightCss = () => `/*
|
|
2323
|
+
* The SDK ships no global CSS on purpose: dropping a space into an existing site must not restyle that site.
|
|
2324
|
+
* The browser's own margins therefore survive unless the host page clears them, which is what this does.
|
|
2325
|
+
*/
|
|
2326
|
+
* {
|
|
2327
|
+
box-sizing: border-box;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
body {
|
|
2331
|
+
margin: 0;
|
|
2332
|
+
}
|
|
2333
|
+
`;
|
|
2334
|
+
var mainFile = () => `import { render } from '@plitzi/plitzi-sdk';
|
|
2335
|
+
|
|
2336
|
+
import { authorSpace } from '@plitzi/sdk-authoring';
|
|
2337
|
+
|
|
2338
|
+
import { declarations } from '../src/declarations.ts';
|
|
2339
|
+
import { elements } from '../src/elements.ts';
|
|
2340
|
+
import { space } from './space.ts';
|
|
2341
|
+
|
|
2342
|
+
import './preflight.css';
|
|
2343
|
+
import '@plitzi/plitzi-sdk/plitzi-sdk.css';
|
|
2344
|
+
|
|
2345
|
+
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* Every element of the package, registered from its source under the type a space names it by — so a save is a hot
|
|
2349
|
+
* module replacement. What a published page does instead is load the built file through the manifest;
|
|
2350
|
+
* \`npm run build\` is what makes that file.
|
|
2351
|
+
*/
|
|
2352
|
+
const plugins = Object.fromEntries(elements.map(element => [element.type, { component: element }]));
|
|
2353
|
+
|
|
2354
|
+
/** The types this package's elements are: named, so authoring takes them for plugins and not for typos. */
|
|
2355
|
+
const pluginTypes = declarations.map(declaration => declaration.type);
|
|
2356
|
+
|
|
2357
|
+
const mount = (spec: SpaceSpec) => {
|
|
2358
|
+
const { schema, style, warnings } = authorSpace(spec, { pluginTypes });
|
|
2359
|
+
for (const warning of warnings) {
|
|
2360
|
+
console.warn(\`[author] \${warning.message}\`);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
return render(
|
|
2364
|
+
'plitzi-root',
|
|
2365
|
+
{
|
|
2366
|
+
// The SDK renders the documents it is handed: no account, no key and no server in the picture.
|
|
2367
|
+
offlineMode: true,
|
|
2368
|
+
offlineData: { schema, style },
|
|
2369
|
+
// Straight into this page rather than into the SDK's default iframe: one document, one stylesheet.
|
|
2370
|
+
renderMode: 'raw',
|
|
2371
|
+
environment: 'main',
|
|
2372
|
+
// The dev tools — shift+alt+D for the panel: logs, the store, the elements, what a flow did.
|
|
2373
|
+
debugMode: import.meta.env.DEV
|
|
2374
|
+
},
|
|
2375
|
+
plugins
|
|
2376
|
+
);
|
|
2377
|
+
};
|
|
2378
|
+
|
|
2379
|
+
let mounted = mount(space);
|
|
2380
|
+
|
|
2381
|
+
if (import.meta.hot) {
|
|
2382
|
+
import.meta.hot.accept('./space.ts', updated => {
|
|
2383
|
+
// Cast because the dev server cannot know the shape of a module it is swapping; the name is this file's own.
|
|
2384
|
+
const next = (updated as { space?: SpaceSpec } | undefined)?.space;
|
|
2385
|
+
if (!next) {
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
mounted?.unmount();
|
|
2390
|
+
mounted = mount(next);
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
`;
|
|
2394
|
+
var previewFiles = ([main, ...others]) => ({
|
|
2395
|
+
"index.html": indexHtml(main),
|
|
2396
|
+
"preview/preflight.css": preflightCss(),
|
|
2397
|
+
"preview/main.ts": mainFile(),
|
|
2398
|
+
"preview/space.ts": blankSpaceSource({
|
|
2399
|
+
name: `${main.title} preview`,
|
|
2400
|
+
plugin: [main, ...others].map((names) => ({
|
|
2401
|
+
id: names.base,
|
|
2402
|
+
renderType: names.type,
|
|
2403
|
+
as: "element",
|
|
2404
|
+
attributes: {
|
|
2405
|
+
label: names.title,
|
|
2406
|
+
start: 0,
|
|
2407
|
+
step: 1
|
|
2408
|
+
}
|
|
2409
|
+
}))
|
|
2410
|
+
})
|
|
2411
|
+
});
|
|
2412
|
+
//#endregion
|
|
2413
|
+
//#region src/scaffold/pluginPackage/source.ts
|
|
2414
|
+
var declarationFile = ({ component: name, type }, { title, description, owner }) => `import type { ${name}Props } from './${name}';
|
|
2415
|
+
import type { PluginDeclaration } from '@plitzi/plitzi-sdk';
|
|
2416
|
+
|
|
2417
|
+
/** What this element can be authored with — its component's own props, minus what the runtime supplies. */
|
|
2418
|
+
export type ${name}Attributes = Omit<${name}Props, 'className'>;
|
|
2419
|
+
|
|
2420
|
+
/**
|
|
2421
|
+
* Static declaration for ${name}: its type, the events it fires and the actions it answers to, and the element the
|
|
2422
|
+
* builder adds when somebody drops it on a page. Data only, no React — the build reads it to write the manifest.
|
|
2423
|
+
*/
|
|
2424
|
+
const declaration = {
|
|
2425
|
+
/** What a space names it by: the \`renderType\` of the \`custom\` element that hosts it. Renaming it orphans every one. */
|
|
2426
|
+
type: '${type}',
|
|
2427
|
+
/** The events it fires. The component registers these; the builder offers them as what a flow can start on. */
|
|
2428
|
+
triggers: {
|
|
2429
|
+
// \`preview\` names what a flow started by the event can read — shown in the builder, never sent.
|
|
2430
|
+
onCount: { action: 'onCount', title: 'On Count', type: 'trigger', params: {}, preview: { count: '' } }
|
|
2431
|
+
},
|
|
2432
|
+
/** The actions it answers to, static half: the component adds the function, and a title naming its label. */
|
|
2433
|
+
callbacks: {
|
|
2434
|
+
reset: { action: 'reset', title: 'Reset', type: 'callback', params: {} }
|
|
2435
|
+
},
|
|
2436
|
+
content: {
|
|
2437
|
+
/** The starting attributes — the component's defaults, written where the builder can show them. */
|
|
2438
|
+
attributes: { label: ${tsString(title)}, start: 0, step: 1 },
|
|
2439
|
+
definition: {
|
|
2440
|
+
label: ${tsString(title)},
|
|
2441
|
+
type: '${type}',
|
|
2442
|
+
/** What it is for: the builder shows it, and an agent connected over MCP reads it to choose the element. */
|
|
2443
|
+
description: ${tsString(description)},
|
|
2444
|
+
items: [],
|
|
2445
|
+
bindings: {},
|
|
2446
|
+
styleSelectors: { base: '' },
|
|
2447
|
+
initialState: { visibility: true }
|
|
2448
|
+
},
|
|
2449
|
+
builder: {
|
|
2450
|
+
canDelete: true,
|
|
2451
|
+
canSelect: true,
|
|
2452
|
+
canDragDrop: true,
|
|
2453
|
+
canMove: true,
|
|
2454
|
+
canTemplate: true,
|
|
2455
|
+
itemsAllowed: [],
|
|
2456
|
+
itemsNotAllowed: []
|
|
2457
|
+
},
|
|
2458
|
+
market: {
|
|
2459
|
+
category: ${tsString(title)},
|
|
2460
|
+
owner: ${tsString(owner)},
|
|
2461
|
+
license: 'MIT',
|
|
2462
|
+
website: '',
|
|
2463
|
+
backgroundColor: '#4422ee',
|
|
2464
|
+
icon: ''
|
|
2465
|
+
},
|
|
2466
|
+
defaultStyle: {
|
|
2467
|
+
name: ${tsString(title)},
|
|
2468
|
+
displayMode: 'desktop',
|
|
2469
|
+
style: { base: { default: {} } },
|
|
2470
|
+
/** The attributes a data source may be pointed at — what the builder offers when somebody connects data to it. */
|
|
2471
|
+
bindingsAllowed: {
|
|
2472
|
+
attributes: [
|
|
2473
|
+
{ path: 'label', label: 'Label' },
|
|
2474
|
+
{ path: 'start', label: 'Starts at' },
|
|
2475
|
+
{ path: 'step', label: 'Step' }
|
|
2476
|
+
],
|
|
2477
|
+
initialState: []
|
|
2478
|
+
}
|
|
2479
|
+
},
|
|
2480
|
+
settings: {}
|
|
2481
|
+
}
|
|
2482
|
+
} satisfies PluginDeclaration<${name}Attributes>;
|
|
2483
|
+
|
|
2484
|
+
export default declaration;
|
|
2485
|
+
`;
|
|
2486
|
+
var component = ({ component: name, title }) => `import { use, useCallback, useMemo, useState } from 'react';
|
|
2487
|
+
|
|
2488
|
+
import { RootElement, useElement, usePlitziServiceContext } from '@plitzi/plitzi-sdk';
|
|
2489
|
+
|
|
2490
|
+
import declaration from './declaration';
|
|
2491
|
+
|
|
2492
|
+
import type { InteractionCallback } from '@plitzi/plitzi-sdk';
|
|
2493
|
+
import type { CSSProperties } from 'react';
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* The props ARE the element's attributes.
|
|
2497
|
+
*
|
|
2498
|
+
* Whatever a space writes on the \`custom\` element that hosts this arrives here by the same name — and so does whatever
|
|
2499
|
+
* a binding writes later, which is what makes a plugin a live component rather than a static one. Everything is
|
|
2500
|
+
* optional and everything has a default: an attribute nobody has authored yet, or a binding whose source has not
|
|
2501
|
+
* answered, is \`undefined\`, and a plugin that renders nothing in that moment is a hole in the page.
|
|
2502
|
+
*/
|
|
2503
|
+
export interface ${name}Props {
|
|
2504
|
+
label?: string;
|
|
2505
|
+
/** Where the count starts, and where \`reset\` puts it back. */
|
|
2506
|
+
start?: number;
|
|
2507
|
+
/** How much one press adds. */
|
|
2508
|
+
step?: number;
|
|
2509
|
+
/** Supplied by the runtime, not authored: the classes the element's own style rules are written against. */
|
|
2510
|
+
className?: string;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* A count somebody raises.
|
|
2515
|
+
*
|
|
2516
|
+
* Deterministic on its first render: it draws the same markup on a server and in the browser, so a server-rendered
|
|
2517
|
+
* page and its hydration agree. React answers a mismatch by throwing away the whole tree it happened in, so a plugin
|
|
2518
|
+
* that renders \`Date.now()\` or reads \`window\` on the first pass does not break itself, it blanks the page. Anything
|
|
2519
|
+
* live belongs in an effect.
|
|
2520
|
+
*/
|
|
2521
|
+
const ${name} = ({ label = ${tsString(title)}, start = 0, step = 1, className }: ${name}Props) => {
|
|
2522
|
+
const { id } = useElement();
|
|
2523
|
+
const {
|
|
2524
|
+
contexts: { InteractionsContext }
|
|
2525
|
+
} = usePlitziServiceContext();
|
|
2526
|
+
const { interactionsManager } = use(InteractionsContext);
|
|
2527
|
+
const [count, setCount] = useState(start);
|
|
2528
|
+
|
|
2529
|
+
const handleAdd = useCallback(() => {
|
|
2530
|
+
const next = count + step;
|
|
2531
|
+
setCount(next);
|
|
2532
|
+
void interactionsManager.interactionTrigger(id, declaration.triggers.onCount.action, { count: next });
|
|
2533
|
+
}, [count, step, interactionsManager, id]);
|
|
2534
|
+
|
|
2535
|
+
const handleReset = useCallback(() => setCount(start), [start]);
|
|
2536
|
+
|
|
2537
|
+
/** The declared events, titled with this element's label so the builder tells two of them apart. */
|
|
2538
|
+
const interactionTriggers = useMemo<Record<string, InteractionCallback>>(
|
|
2539
|
+
() => ({ onCount: { ...declaration.triggers.onCount, title: \`\${label} counted\` } }),
|
|
2540
|
+
[label]
|
|
2541
|
+
);
|
|
2542
|
+
|
|
2543
|
+
/** The declared actions, with what only a mounted element has: the function that does it. */
|
|
2544
|
+
const interactionCallbacks = useMemo<Record<string, InteractionCallback>>(
|
|
2545
|
+
() => ({ reset: { ...declaration.callbacks.reset, title: \`Reset \${label}\`, callback: handleReset } }),
|
|
2546
|
+
[label, handleReset]
|
|
2547
|
+
);
|
|
2548
|
+
|
|
2549
|
+
/**
|
|
2550
|
+
* \`RootElement\` is the root, and not a \`div\`.
|
|
2551
|
+
*
|
|
2552
|
+
* It is what makes this an ELEMENT rather than a component that happens to be on the page: the element's id and
|
|
2553
|
+
* classes land on it, so the CSS authored on it applies, the builder can select it, a test can find it by name, and
|
|
2554
|
+
* the events and actions above are this element's.
|
|
2555
|
+
*/
|
|
2556
|
+
return (
|
|
2557
|
+
<RootElement
|
|
2558
|
+
className={className}
|
|
2559
|
+
style={CARD}
|
|
2560
|
+
interactionTriggers={interactionTriggers}
|
|
2561
|
+
interactionCallbacks={interactionCallbacks}
|
|
2562
|
+
>
|
|
2563
|
+
<span style={LABEL}>{label}</span>
|
|
2564
|
+
{/* A status: announced when it changes, and what a test finds the count by. */}
|
|
2565
|
+
<span style={VALUE} role="status">
|
|
2566
|
+
{count}
|
|
2567
|
+
</span>
|
|
2568
|
+
<button type="button" style={BUTTON} onClick={handleAdd}>
|
|
2569
|
+
+{step}
|
|
2570
|
+
</button>
|
|
2571
|
+
</RootElement>
|
|
2572
|
+
);
|
|
2573
|
+
};
|
|
2574
|
+
|
|
2575
|
+
/**
|
|
2576
|
+
* Styles inline, and colours borrowed rather than chosen.
|
|
2577
|
+
*
|
|
2578
|
+
* A plugin is dropped into a page whose palette it does not know, so naming a colour here is how a component ends up
|
|
2579
|
+
* black on black the first time somebody switches the theme. \`currentColor\` and the space's own variables follow
|
|
2580
|
+
* whatever the page decided. The element's own look belongs in \`defaultStyle\` in the declaration, where the space can
|
|
2581
|
+
* restyle it; these are only what makes it usable before anybody does.
|
|
2582
|
+
*/
|
|
2583
|
+
const CARD: CSSProperties = {
|
|
2584
|
+
display: 'inline-flex',
|
|
2585
|
+
alignItems: 'center',
|
|
2586
|
+
gap: '12px',
|
|
2587
|
+
padding: '12px 16px',
|
|
2588
|
+
borderRadius: '12px',
|
|
2589
|
+
border: '1px solid color-mix(in srgb, currentColor 15%, transparent)',
|
|
2590
|
+
background: 'color-mix(in srgb, currentColor 6%, transparent)'
|
|
2591
|
+
};
|
|
2592
|
+
|
|
2593
|
+
const LABEL: CSSProperties = { fontSize: '12px', letterSpacing: '0.08em', textTransform: 'uppercase', opacity: 0.65 };
|
|
2594
|
+
|
|
2595
|
+
const VALUE: CSSProperties = { fontSize: '24px', fontWeight: 600, fontVariantNumeric: 'tabular-nums' };
|
|
2596
|
+
|
|
2597
|
+
const BUTTON: CSSProperties = {
|
|
2598
|
+
font: 'inherit',
|
|
2599
|
+
color: 'inherit',
|
|
2600
|
+
padding: '4px 12px',
|
|
2601
|
+
borderRadius: '8px',
|
|
2602
|
+
border: '1px solid color-mix(in srgb, currentColor 25%, transparent)',
|
|
2603
|
+
background: 'transparent',
|
|
2604
|
+
cursor: 'pointer'
|
|
2605
|
+
};
|
|
2606
|
+
|
|
2607
|
+
export default ${name};
|
|
2608
|
+
`;
|
|
2609
|
+
var settings = ({ component: name }) => `import { useCallback } from 'react';
|
|
2610
|
+
|
|
2611
|
+
import type { ${name}Attributes } from './declaration';
|
|
2612
|
+
import type { ChangeEvent, CSSProperties } from 'react';
|
|
2613
|
+
|
|
2614
|
+
/**
|
|
2615
|
+
* The builder's panel for the element.
|
|
2616
|
+
*
|
|
2617
|
+
* The builder hands it the element's attributes as props and gives it \`onUpdate\` to write one back — the same
|
|
2618
|
+
* attributes the component reads, so what is edited here is what renders. Only the builder loads it: a published page
|
|
2619
|
+
* never draws a panel.
|
|
2620
|
+
*/
|
|
2621
|
+
export type SettingsProps = ${name}Attributes & {
|
|
2622
|
+
onUpdate?: (attribute: string, value: string | number) => void;
|
|
2623
|
+
};
|
|
2624
|
+
|
|
2625
|
+
const Settings = ({ label = '', start = 0, step = 1, onUpdate }: SettingsProps) => {
|
|
2626
|
+
const handleLabel = useCallback(
|
|
2627
|
+
(event: ChangeEvent<HTMLInputElement>) => onUpdate?.('label', event.target.value),
|
|
2628
|
+
[onUpdate]
|
|
2629
|
+
);
|
|
2630
|
+
const handleStart = useCallback(
|
|
2631
|
+
(event: ChangeEvent<HTMLInputElement>) => onUpdate?.('start', Number(event.target.value)),
|
|
2632
|
+
[onUpdate]
|
|
2633
|
+
);
|
|
2634
|
+
const handleStep = useCallback(
|
|
2635
|
+
(event: ChangeEvent<HTMLInputElement>) => onUpdate?.('step', Number(event.target.value)),
|
|
2636
|
+
[onUpdate]
|
|
2637
|
+
);
|
|
2638
|
+
|
|
2639
|
+
return (
|
|
2640
|
+
<div style={PANEL}>
|
|
2641
|
+
<label style={FIELD}>
|
|
2642
|
+
Label
|
|
2643
|
+
<input style={INPUT} value={label} onChange={handleLabel} />
|
|
2644
|
+
</label>
|
|
2645
|
+
<label style={FIELD}>
|
|
2646
|
+
Starts at
|
|
2647
|
+
<input style={INPUT} type="number" value={start} onChange={handleStart} />
|
|
2648
|
+
</label>
|
|
2649
|
+
<label style={FIELD}>
|
|
2650
|
+
Step
|
|
2651
|
+
<input style={INPUT} type="number" min={1} value={step} onChange={handleStep} />
|
|
2652
|
+
</label>
|
|
2653
|
+
</div>
|
|
2654
|
+
);
|
|
2655
|
+
};
|
|
2656
|
+
|
|
2657
|
+
/** The builder's own theme is light or dark, so the panel borrows its colours the way the component does. */
|
|
2658
|
+
const PANEL: CSSProperties = { display: 'flex', flexDirection: 'column', gap: '12px', padding: '8px 0' };
|
|
2659
|
+
|
|
2660
|
+
const FIELD: CSSProperties = { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' };
|
|
2661
|
+
|
|
2662
|
+
const INPUT: CSSProperties = {
|
|
2663
|
+
font: 'inherit',
|
|
2664
|
+
color: 'inherit',
|
|
2665
|
+
padding: '6px 8px',
|
|
2666
|
+
borderRadius: '6px',
|
|
2667
|
+
border: '1px solid color-mix(in srgb, currentColor 25%, transparent)',
|
|
2668
|
+
background: 'transparent'
|
|
2669
|
+
};
|
|
2670
|
+
|
|
2671
|
+
export default Settings;
|
|
2672
|
+
`;
|
|
2673
|
+
var elementIndex = ({ component: name }) => `import Base${name} from './${name}';
|
|
2674
|
+
import declaration from './declaration';
|
|
2675
|
+
import Settings from './Settings';
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* The element, as whatever registers it reads it: the component, everything its declaration says — its \`type\` first
|
|
2679
|
+
* — and the panel the builder edits it with. The same three things Plitzi's own elements are made of.
|
|
2680
|
+
*/
|
|
2681
|
+
const ${name} = Object.assign(Base${name}, declaration, { pluginSettings: Settings });
|
|
2682
|
+
|
|
2683
|
+
export * from './${name}';
|
|
2684
|
+
|
|
2685
|
+
export type { ${name}Attributes } from './declaration';
|
|
2686
|
+
|
|
2687
|
+
export default ${name};
|
|
2688
|
+
`;
|
|
2689
|
+
/** A type's spelling as a variable: `seatPicker` for `SeatPicker`. */
|
|
2690
|
+
var typeOf = (component) => `${component.charAt(0).toLowerCase()}${component.slice(1)}`;
|
|
2691
|
+
/**
|
|
2692
|
+
* `export const <name> = [...]`, laid out the way Prettier lays it out — on one line while it fits in 120 columns,
|
|
2693
|
+
* one entry per line past that — so a list the CLI rewrote is a list the project's formatter leaves alone.
|
|
2694
|
+
*/
|
|
2695
|
+
var listSource = (name, entries) => {
|
|
2696
|
+
const inline = `export const ${name} = [${entries.join(", ")}];`;
|
|
2697
|
+
return inline.length <= 120 ? inline : `export const ${name} = [\n${entries.map((entry) => ` ${entry}`).join(",\n")}\n];`;
|
|
2698
|
+
};
|
|
2699
|
+
/**
|
|
2700
|
+
* `src/elements.ts`: every element the package holds, the one it is named after first. Written from the folders'
|
|
2701
|
+
* names alone, so the CLI can tell a list it wrote from one somebody changed — and add to the first safely.
|
|
2702
|
+
*/
|
|
2703
|
+
var elementsRegistry = (components) => `${components.map((component) => `import ${component} from './${component}';`).join("\n")}
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* Every element this package holds, the one it is named after first. \`plitzi add plugin\` writes this list — add an
|
|
2707
|
+
* element with it, or by hand: its folder, and a line here and in \`declarations.ts\`.
|
|
2708
|
+
*/
|
|
2709
|
+
${listSource("elements", components)}
|
|
2710
|
+
`;
|
|
2711
|
+
/** `src/declarations.ts`: the same elements' declarations, which the build reads without loading any React. */
|
|
2712
|
+
var declarationsRegistry = (components) => `${components.map((component) => `import ${typeOf(component)} from './${component}/declaration';`).join("\n")}
|
|
2713
|
+
|
|
2714
|
+
/**
|
|
2715
|
+
* Every element's declaration, in the order of \`elements.ts\`: the build writes each into \`plugin-manifest.json\`, and
|
|
2716
|
+
* the first is the one a space's \`plugins\` entry loads the package by. \`plitzi add plugin\` writes this list.
|
|
2717
|
+
*/
|
|
2718
|
+
${listSource("declarations", components.map(typeOf))}
|
|
2719
|
+
`;
|
|
2720
|
+
var entry = () => `import { elements } from './elements';
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* What a space loads: the element the package is named after, and every other element it holds as \`plugins\`, by
|
|
2724
|
+
* type. Nothing here changes when one is added — \`plitzi add plugin\` writes its folder and lists it in \`elements.ts\`.
|
|
2725
|
+
*/
|
|
2726
|
+
const [main, ...others] = elements;
|
|
2727
|
+
|
|
2728
|
+
export const plugins = Object.fromEntries(others.map(element => [element.type, element]));
|
|
2729
|
+
|
|
2730
|
+
/** Every element, for a project that installs the package and registers them itself. */
|
|
2731
|
+
export { elements };
|
|
2732
|
+
|
|
2733
|
+
export default main;
|
|
2734
|
+
`;
|
|
2735
|
+
/** The element alone: its folder's files, by their path inside it. */
|
|
2736
|
+
var elementFiles = (names, text) => ({
|
|
2737
|
+
[`${names.component}.tsx`]: component(names),
|
|
2738
|
+
"declaration.ts": declarationFile(names, text),
|
|
2739
|
+
"Settings.tsx": settings(names),
|
|
2740
|
+
"index.ts": elementIndex(names)
|
|
2741
|
+
});
|
|
2742
|
+
/** Every element of a plugin package in its own folder, the two lists that gather them, and the entry. */
|
|
2743
|
+
var packageSourceFiles = (elements) => {
|
|
2744
|
+
const components = elements.map(({ names }) => names.component);
|
|
2745
|
+
return {
|
|
2746
|
+
...Object.fromEntries(elements.flatMap(({ names, text }) => Object.entries(elementFiles(names, text)).map(([file, contents]) => [`src/${names.component}/${file}`, contents]))),
|
|
2747
|
+
"src/elements.ts": elementsRegistry(components),
|
|
2748
|
+
"src/declarations.ts": declarationsRegistry(components),
|
|
2749
|
+
"src/index.ts": entry()
|
|
2750
|
+
};
|
|
2751
|
+
};
|
|
2752
|
+
//#endregion
|
|
2753
|
+
//#region src/scaffold/pluginPackage/visual.ts
|
|
2754
|
+
/**
|
|
2755
|
+
* A browser opens the preview and checks the plugin works in a space: that it is there, and that it does what it says.
|
|
2756
|
+
*
|
|
2757
|
+
* What goes wrong with a plugin rarely throws. The element renders "Custom Component … Not Found", or renders and
|
|
2758
|
+
* cannot be clicked, or hydrates blank — and the preview looks fine to somebody who did not scroll to it.
|
|
2759
|
+
*/
|
|
2760
|
+
var playwrightConfig = ({ packageManager }) => `import { defineConfig } from '@playwright/test';
|
|
2761
|
+
|
|
2762
|
+
const PORT = 5173;
|
|
2763
|
+
|
|
2764
|
+
export default defineConfig({
|
|
2765
|
+
testDir: './visual',
|
|
2766
|
+
outputDir: './visual/.results',
|
|
2767
|
+
use: { baseURL: \`http://127.0.0.1:\${PORT}\` },
|
|
2768
|
+
// Playwright starts the preview itself, so \`${runCommand(packageManager, "visual")}\` is one command from a cold checkout.
|
|
2769
|
+
webServer: {
|
|
2770
|
+
command: '${runCommand(packageManager, "start")}',
|
|
2771
|
+
url: \`http://127.0.0.1:\${PORT}\`,
|
|
2772
|
+
reuseExistingServer: true,
|
|
2773
|
+
timeout: 120_000
|
|
2774
|
+
}
|
|
2775
|
+
});
|
|
2776
|
+
`;
|
|
2777
|
+
var spec = (elements) => `import { expect, test } from '@playwright/test';
|
|
2778
|
+
|
|
2779
|
+
import { authorSpace, inspectPage } from '@plitzi/sdk-authoring';
|
|
2780
|
+
|
|
2781
|
+
import { space } from '../preview/space.ts';
|
|
2782
|
+
import { declarations } from '../src/declarations.ts';
|
|
2783
|
+
|
|
2784
|
+
/** The preview's elements, by the id the preview space gives each and the label it shows. */
|
|
2785
|
+
const ELEMENTS = [
|
|
2786
|
+
${elements.map(({ base, title }) => ` { id: '${base}', label: ${tsString(title)} }`).join(",\n")}
|
|
2787
|
+
];
|
|
2788
|
+
|
|
2789
|
+
/**
|
|
2790
|
+
* Each element, in the preview's space: on screen and answering a click.
|
|
2791
|
+
*
|
|
2792
|
+
* Found by its element id, which is what \`RootElement\` writes on it — so this also fails if a component stops
|
|
2793
|
+
* rendering its root through \`RootElement\`, the mistake that leaves a plugin looking right and being unreachable.
|
|
2794
|
+
*/
|
|
2795
|
+
for (const { id, label } of ELEMENTS) {
|
|
2796
|
+
test(\`\${id} renders in a space, and counts\`, async ({ page }) => {
|
|
2797
|
+
await page.goto('/', { waitUntil: 'networkidle' });
|
|
2798
|
+
|
|
2799
|
+
const element = page.locator(\`[data-plitzi-el="\${id}"]\`);
|
|
2800
|
+
await expect(element).toBeVisible();
|
|
2801
|
+
await expect(element).toContainText(label);
|
|
2802
|
+
|
|
2803
|
+
await expect(element.getByRole('status')).toHaveText('0');
|
|
2804
|
+
await element.getByRole('button').click();
|
|
2805
|
+
await expect(element.getByRole('status')).toHaveText('1');
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/**
|
|
2810
|
+
* The rest of the page too: an element that overflows or hides its neighbours is a page problem. And the space says
|
|
2811
|
+
* nothing it would have to be told twice — authoring knows the package's types for plugins, not typos.
|
|
2812
|
+
*/
|
|
2813
|
+
test('leaves the page whole', async ({ page }) => {
|
|
2814
|
+
const errors: string[] = [];
|
|
2815
|
+
page.on('pageerror', error => errors.push(error.message));
|
|
2816
|
+
|
|
2817
|
+
await page.goto('/', { waitUntil: 'networkidle' });
|
|
2818
|
+
|
|
2819
|
+
const { handles, warnings } = authorSpace(space, { pluginTypes: declarations.map(declaration => declaration.type) });
|
|
2820
|
+
expect(warnings).toEqual([]);
|
|
2821
|
+
expect((await inspectPage(page, handles)).problems).toEqual([]);
|
|
2822
|
+
expect(errors).toEqual([]);
|
|
2823
|
+
});
|
|
2824
|
+
`;
|
|
2825
|
+
var visualFiles = (elements, answers) => ({
|
|
2826
|
+
"playwright.config.ts": playwrightConfig(answers),
|
|
2827
|
+
"visual/plugin.spec.ts": spec(elements)
|
|
2828
|
+
});
|
|
2829
|
+
//#endregion
|
|
2830
|
+
//#region src/scaffold/pluginPackage/index.ts
|
|
2831
|
+
/** What the build writes, and what is nobody's to format or lint. */
|
|
2832
|
+
var OUTPUTS = ["dist", "visual/.results"];
|
|
2833
|
+
/**
|
|
2834
|
+
* Every file of a plugin package: the element, the build that publishes it, a space to preview it in, and what checks
|
|
2835
|
+
* it — a project of its own, with nothing about it shared with whatever repository it sits in except the folder.
|
|
2836
|
+
*/
|
|
2837
|
+
var scaffoldPlugin = (answers) => {
|
|
2838
|
+
const names = pluginNames(answers.packageName);
|
|
2839
|
+
const elements = answers.elements.map((element) => ({
|
|
2840
|
+
names: {
|
|
2841
|
+
...pluginNames(element.name),
|
|
2842
|
+
title: element.title
|
|
2843
|
+
},
|
|
2844
|
+
text: {
|
|
2845
|
+
title: element.title,
|
|
2846
|
+
description: element.description,
|
|
2847
|
+
owner: answers.owner
|
|
2848
|
+
}
|
|
2849
|
+
}));
|
|
2850
|
+
const elementNames = elements.map((element) => element.names);
|
|
2851
|
+
return {
|
|
2852
|
+
...answers.inProject ? {} : managerFiles(answers.packageManager, answers.managerVersion),
|
|
2853
|
+
...packageFiles(names, answers),
|
|
2854
|
+
...qualityFilesFor("browser", OUTPUTS),
|
|
2855
|
+
...packageSourceFiles(elements),
|
|
2856
|
+
...buildFiles(),
|
|
2857
|
+
...previewFiles(elementNames),
|
|
2858
|
+
...visualFiles(elementNames, answers),
|
|
2859
|
+
...docsFiles(elementNames, answers),
|
|
2860
|
+
...skillFiles(["plitzi-cli"])
|
|
2861
|
+
};
|
|
2862
|
+
};
|
|
2863
|
+
/**
|
|
2864
|
+
* One element, for a folder of a project that already exists: paths relative to the element's own folder. `title` is
|
|
2865
|
+
* the label it renders until an attribute says otherwise.
|
|
2866
|
+
*/
|
|
2867
|
+
var scaffoldElement = (name, text) => elementFiles({
|
|
2868
|
+
...pluginNames(name),
|
|
2869
|
+
title: text.title
|
|
2870
|
+
}, text);
|
|
2871
|
+
//#endregion
|
|
2872
|
+
//#region src/scaffold/index.ts
|
|
2873
|
+
/**
|
|
2874
|
+
* Every file of a generated project, assembled from the two decisions that shape it.
|
|
2875
|
+
*
|
|
2876
|
+
* Pure, and separate from the command that writes them, so what the scaffold SAYS can be asserted without a
|
|
1191
2877
|
* filesystem — and because the interesting part of this feature is the contents. A scaffold that produces a
|
|
1192
2878
|
* project nobody can read is one its owner replaces rather than learns from, so each file carries the reasoning
|
|
1193
2879
|
* that would otherwise live in documentation they have not opened.
|
|
1194
2880
|
*/
|
|
1195
|
-
var scaffold = (answers) => ({
|
|
1196
|
-
...projectFiles(answers),
|
|
1197
|
-
...qualityFiles(answers),
|
|
1198
|
-
...spaceFiles(answers),
|
|
1199
|
-
...pluginFiles(answers),
|
|
1200
|
-
...answers.mode === "server" ? serverFiles(answers) : clientFiles(answers),
|
|
1201
|
-
...visualFiles(answers),
|
|
1202
|
-
...skillFiles()
|
|
2881
|
+
var scaffold = (answers) => ({
|
|
2882
|
+
...projectFiles(answers),
|
|
2883
|
+
...qualityFiles(answers),
|
|
2884
|
+
...spaceFiles(answers),
|
|
2885
|
+
...pluginFiles(answers),
|
|
2886
|
+
...answers.mode === "server" ? serverFiles(answers) : clientFiles(answers),
|
|
2887
|
+
...visualFiles$1(answers),
|
|
2888
|
+
...skillFiles()
|
|
2889
|
+
});
|
|
2890
|
+
//#endregion
|
|
2891
|
+
//#region src/commands/existingProject.ts
|
|
2892
|
+
var LOCKFILES = [
|
|
2893
|
+
["yarn.lock", "yarn"],
|
|
2894
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
2895
|
+
["package-lock.json", "npm"]
|
|
2896
|
+
];
|
|
2897
|
+
var exists = async (file) => {
|
|
2898
|
+
try {
|
|
2899
|
+
await fs.access(file);
|
|
2900
|
+
return true;
|
|
2901
|
+
} catch {
|
|
2902
|
+
return false;
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
var readPackageJson = async (dir) => {
|
|
2906
|
+
try {
|
|
2907
|
+
return JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf-8"));
|
|
2908
|
+
} catch {
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
/** The workspace globs a root declares — npm's and Yarn's `workspaces`, or pnpm's own file. */
|
|
2913
|
+
var workspaceGlobs = async (dir, packageJson) => {
|
|
2914
|
+
const { workspaces } = packageJson;
|
|
2915
|
+
if (Array.isArray(workspaces)) return workspaces;
|
|
2916
|
+
if (workspaces?.packages) return workspaces.packages;
|
|
2917
|
+
try {
|
|
2918
|
+
const pnpm = await fs.readFile(path.join(dir, "pnpm-workspace.yaml"), "utf-8");
|
|
2919
|
+
return [...(/^packages:\s*\n((?:\s+-\s*.+\n?)+)/m.exec(pnpm)?.[1] ?? "").matchAll(/-\s*['"]?([^'"\n]+)['"]?/g)].map((match) => match[1].trim());
|
|
2920
|
+
} catch {
|
|
2921
|
+
return [];
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
/** From every directory upward, the first that holds a `package.json`. */
|
|
2925
|
+
var nearestPackage = async (from) => {
|
|
2926
|
+
let dir = from;
|
|
2927
|
+
for (;;) {
|
|
2928
|
+
if (await exists(path.join(dir, "package.json"))) return dir;
|
|
2929
|
+
const parent = path.dirname(dir);
|
|
2930
|
+
if (parent === dir) return;
|
|
2931
|
+
dir = parent;
|
|
2932
|
+
}
|
|
2933
|
+
};
|
|
2934
|
+
/** The first directory at or above `from` that declares workspaces, with what it declares. */
|
|
2935
|
+
var nearestWorkspace = async (from) => {
|
|
2936
|
+
for (let dir = await nearestPackage(from); dir; dir = await nearestPackage(path.dirname(dir))) {
|
|
2937
|
+
const packageJson = await readPackageJson(dir);
|
|
2938
|
+
const globs = packageJson ? await workspaceGlobs(dir, packageJson) : [];
|
|
2939
|
+
if (globs.length > 0) return {
|
|
2940
|
+
root: dir,
|
|
2941
|
+
globs
|
|
2942
|
+
};
|
|
2943
|
+
if (path.dirname(dir) === dir) return;
|
|
2944
|
+
}
|
|
2945
|
+
};
|
|
2946
|
+
var lockfileManager = async (dirs) => {
|
|
2947
|
+
for (const dir of dirs) for (const [file, manager] of LOCKFILES) if (await exists(path.join(dir, file))) return manager;
|
|
2948
|
+
};
|
|
2949
|
+
var readText = async (file) => {
|
|
2950
|
+
try {
|
|
2951
|
+
return await fs.readFile(file, "utf-8");
|
|
2952
|
+
} catch {
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
/**
|
|
2957
|
+
* A plugin package's elements, read off its `src/elements.ts` — and trusted only when writing them back out gives both
|
|
2958
|
+
* of its lists byte for byte, which is what says nobody has changed them since the CLI did.
|
|
2959
|
+
*/
|
|
2960
|
+
var packageComponents = async (root) => {
|
|
2961
|
+
const [elements, declarations] = await Promise.all([readText(path.join(root, "src/elements.ts")), readText(path.join(root, "src/declarations.ts"))]);
|
|
2962
|
+
if (elements === void 0 || declarations === void 0) return;
|
|
2963
|
+
const components = [...elements.matchAll(/^import (\w+) from '\.\/(\w+)';$/gm)].filter(([, name, folder]) => name === folder).map(([, name]) => name);
|
|
2964
|
+
return components.length > 0 && elementsRegistry(components) === elements && declarationsRegistry(components) === declarations ? components : void 0;
|
|
2965
|
+
};
|
|
2966
|
+
/**
|
|
2967
|
+
* A project's declared plugins, read off `src/plugins/declarations.ts` — and trusted only when writing them back out
|
|
2968
|
+
* gives the file byte for byte, as with a plugin package's lists.
|
|
2969
|
+
*/
|
|
2970
|
+
var projectDeclared = async (root) => {
|
|
2971
|
+
const listed = await readText(path.join(root, "src/plugins/declarations.ts"));
|
|
2972
|
+
if (listed === void 0) return;
|
|
2973
|
+
const components = [...listed.matchAll(/^import \w+ from '\.\/(\w+)\/declaration\.ts';$/gm)].map(([, folder]) => folder);
|
|
2974
|
+
return projectDeclarations(components) === listed ? components : void 0;
|
|
2975
|
+
};
|
|
2976
|
+
/**
|
|
2977
|
+
* What `plitzi create` wrote, if it did — told by what the project depends on and the files it keeps, never by its
|
|
2978
|
+
* name.
|
|
2979
|
+
*
|
|
2980
|
+
* - A plugin package publishes the SDK as a peer and keeps its elements' lists in `src/`.
|
|
2981
|
+
* - A project renders a space: the SDK is a dependency and `src/main.ts` registers the project's plugins — by folder
|
|
2982
|
+
* in a project written since that was how, by a list written in the file before then.
|
|
2983
|
+
*/
|
|
2984
|
+
var plitziProject = async (root, packageJson) => {
|
|
2985
|
+
if (packageJson.peerDependencies?.["@plitzi/plitzi-sdk"] && await exists(path.join(root, "src/elements.ts"))) return {
|
|
2986
|
+
kind: "plugin",
|
|
2987
|
+
components: await packageComponents(root)
|
|
2988
|
+
};
|
|
2989
|
+
const dependencies = {
|
|
2990
|
+
...packageJson.dependencies,
|
|
2991
|
+
...packageJson.devDependencies
|
|
2992
|
+
};
|
|
2993
|
+
const main = await readText(path.join(root, "src/main.ts"));
|
|
2994
|
+
if (!("@plitzi/plitzi-sdk" in dependencies) || main === void 0 || !/src\/plugins|\.\/plugins\//.test(main)) return;
|
|
2995
|
+
const server = "@plitzi/sdk-server" in dependencies;
|
|
2996
|
+
return {
|
|
2997
|
+
kind: "project",
|
|
2998
|
+
mode: server ? "server" : "client",
|
|
2999
|
+
source: await exists(path.join(root, "src/space.ts")) ? "local" : "cloud",
|
|
3000
|
+
discovers: server ? main.includes("readdirSync(PLUGINS_DIR") : main.includes("import.meta.glob<{ default: RenderPlugins[string]"),
|
|
3001
|
+
declared: await projectDeclared(root)
|
|
3002
|
+
};
|
|
3003
|
+
};
|
|
3004
|
+
/** The project `from` is inside, or `undefined` when no directory above it has a `package.json`. */
|
|
3005
|
+
var findProject = async (from) => {
|
|
3006
|
+
const root = await nearestPackage(from);
|
|
3007
|
+
if (!root) return;
|
|
3008
|
+
const packageJson = await readPackageJson(root) ?? {};
|
|
3009
|
+
const workspace = await nearestWorkspace(root);
|
|
3010
|
+
const workspaceFolders = (workspace?.globs ?? []).filter((glob) => glob.endsWith("/*") && !glob.startsWith("!")).map((glob) => glob.slice(0, -2));
|
|
3011
|
+
return {
|
|
3012
|
+
root,
|
|
3013
|
+
packageManager: await lockfileManager(workspace ? [root, workspace.root] : [root]),
|
|
3014
|
+
workspaceRoot: workspace?.root,
|
|
3015
|
+
workspaceFolders,
|
|
3016
|
+
plitzi: await plitziProject(root, packageJson)
|
|
3017
|
+
};
|
|
3018
|
+
};
|
|
3019
|
+
/** Whether `dir` is one of the workspace's packages — a folder its globs cover — so an install there installs it. */
|
|
3020
|
+
var coveredByWorkspace = (project, dir) => project.workspaceRoot !== void 0 && project.workspaceFolders.some((folder) => path.dirname(dir) === path.join(project.workspaceRoot ?? "", folder));
|
|
3021
|
+
//#endregion
|
|
3022
|
+
//#region src/commands/terminal.ts
|
|
3023
|
+
/** Somebody who can answer a prompt. An agent's shell, CI and a pipe are nobody, whatever they were asked to do. */
|
|
3024
|
+
var atTerminal = () => process.stdin.isTTY && process.stdout.isTTY;
|
|
3025
|
+
/**
|
|
3026
|
+
* Nobody to ask, so the questions go to whoever ran this — in practice an agent — to put to the person.
|
|
3027
|
+
*
|
|
3028
|
+
* It offers no way around them. It used to end with "or with --yes to take the defaults", and an agent keen to finish
|
|
3029
|
+
* took that exit every time: the person was never asked, which is the whole failure this stop exists to prevent.
|
|
3030
|
+
*/
|
|
3031
|
+
var refuseWithoutTerminal = (what, questions, stopped = `plitzi create stopped before writing anything: these choices shape the whole ${what}`) => {
|
|
3032
|
+
console.error(chalk.red(`\n${stopped}, they belong to the person it is for, and nobody is at this terminal to make them.`));
|
|
3033
|
+
console.error(chalk.bold("\nIf you are an AI agent: ask the user each question below, show them the options, and wait for their answers. Do not choose for them and do not fall back to a default.\n"));
|
|
3034
|
+
questions.forEach(({ flag, choices, question }, index) => {
|
|
3035
|
+
console.error(` ${index + 1}. ${question}`);
|
|
3036
|
+
console.error(` ${flag} ${choices.join(" | ")}`);
|
|
3037
|
+
});
|
|
3038
|
+
const flags = questions.map(({ flag }) => `${flag} <their answer>`).join(" ");
|
|
3039
|
+
console.error(`\nThen run the same command again with their answers: ${flags}`);
|
|
3040
|
+
process.exitCode = 1;
|
|
3041
|
+
};
|
|
3042
|
+
/** One choice at the terminal: Enter takes the default, anything off the list is refused rather than guessed at. */
|
|
3043
|
+
var ask = async (rl, question) => {
|
|
3044
|
+
const reply = (await rl.question(`\n${question.question}\n${question.flag} (${question.choices.join("/")}) [${question.fallback}] > `)).trim();
|
|
3045
|
+
const chosen = question.choices.find((choice) => choice === (reply || question.fallback));
|
|
3046
|
+
if (!chosen) console.error(chalk.red(`"${reply}" is not one of ${question.choices.join(", ")}.`));
|
|
3047
|
+
return chosen;
|
|
3048
|
+
};
|
|
3049
|
+
/**
|
|
3050
|
+
* A free answer at the terminal, with the likely one offered. `check` answers what is wrong with a reply, if anything:
|
|
3051
|
+
* a refused reply is said and asked again, since a name or a path is typed rather than picked and a typo is the
|
|
3052
|
+
* usual reason for one.
|
|
3053
|
+
*/
|
|
3054
|
+
var askText = async (rl, question, fallback, check = () => void 0) => {
|
|
3055
|
+
for (;;) {
|
|
3056
|
+
const offered = fallback ? `[${fallback}]` : "(optional)";
|
|
3057
|
+
const reply = (await rl.question(`\n${question}\n${offered} > `)).trim() || fallback;
|
|
3058
|
+
const problem = check(reply);
|
|
3059
|
+
if (!problem) return reply;
|
|
3060
|
+
console.error(chalk.red(problem));
|
|
3061
|
+
}
|
|
3062
|
+
};
|
|
3063
|
+
/**
|
|
3064
|
+
* One of a numbered list, or something else typed out. Enter takes the first entry. `other` is the question for the
|
|
3065
|
+
* typed answer, offered as the last number.
|
|
3066
|
+
*/
|
|
3067
|
+
var askChoice = async (rl, question, options, other) => {
|
|
3068
|
+
const menu = [...options.map((option) => option.label), other.label].map((label, index) => ` ${index + 1}. ${label}`).join("\n");
|
|
3069
|
+
for (;;) {
|
|
3070
|
+
const reply = (await rl.question(`\n${question}\n${menu}\n[1] > `)).trim() || "1";
|
|
3071
|
+
const index = Number(reply) - 1;
|
|
3072
|
+
if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
|
|
3073
|
+
if (index === options.length) return askText(rl, other.question, other.fallback, other.check);
|
|
3074
|
+
console.error(chalk.red(`"${reply}" is not a number from 1 to ${options.length + 1}.`));
|
|
3075
|
+
}
|
|
3076
|
+
};
|
|
3077
|
+
/** One of a numbered list and nothing else. Enter takes the first entry. */
|
|
3078
|
+
var askPick = async (rl, question, options) => {
|
|
3079
|
+
const menu = options.map(({ label }, index) => ` ${index + 1}. ${label}`).join("\n");
|
|
3080
|
+
for (;;) {
|
|
3081
|
+
const reply = (await rl.question(`\n${question}\n${menu}\n[1] > `)).trim() || "1";
|
|
3082
|
+
const index = Number(reply) - 1;
|
|
3083
|
+
if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
|
|
3084
|
+
console.error(chalk.red(`"${reply}" is not a number from 1 to ${options.length}.`));
|
|
3085
|
+
}
|
|
3086
|
+
};
|
|
3087
|
+
/** A directory that does not exist yet is as empty as one can be, which is the answer this question wants. */
|
|
3088
|
+
var isEmpty = async (target) => {
|
|
3089
|
+
try {
|
|
3090
|
+
return (await fs.readdir(target)).length === 0;
|
|
3091
|
+
} catch {
|
|
3092
|
+
return true;
|
|
3093
|
+
}
|
|
3094
|
+
};
|
|
3095
|
+
var isDirectory$1 = async (candidate) => {
|
|
3096
|
+
try {
|
|
3097
|
+
return (await fs.stat(candidate)).isDirectory();
|
|
3098
|
+
} catch {
|
|
3099
|
+
return false;
|
|
3100
|
+
}
|
|
3101
|
+
};
|
|
3102
|
+
/** The closest directory that already exists: the project's own does not until its files are written. */
|
|
3103
|
+
var nearestExisting = async (target) => {
|
|
3104
|
+
let dir = target;
|
|
3105
|
+
while (!await isDirectory$1(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
|
|
3106
|
+
return dir;
|
|
3107
|
+
};
|
|
3108
|
+
/** Every file, by the path under `target` it is written to. */
|
|
3109
|
+
var writeFiles = async (target, files) => {
|
|
3110
|
+
await Promise.all(Object.entries(files).map(async ([file, contents]) => {
|
|
3111
|
+
const destination = path.join(target, file);
|
|
3112
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
3113
|
+
return fs.writeFile(destination, contents);
|
|
3114
|
+
}));
|
|
3115
|
+
};
|
|
3116
|
+
/**
|
|
3117
|
+
* What a first install is most likely refused over, and what to do about it.
|
|
3118
|
+
*
|
|
3119
|
+
* The scaffold already exempts what it ships, but not the rest of the tree: a third-party dependency published in
|
|
3120
|
+
* the last day is held back by the same age gates, and says so in a code that means nothing to somebody who has
|
|
3121
|
+
* not touched the project yet.
|
|
3122
|
+
*/
|
|
3123
|
+
var INSTALL_HINTS = {
|
|
3124
|
+
npm: "A peer-dependency conflict (ERESOLVE) is a bug in this scaffold: please report it. `npm install --legacy-peer-deps` gets past it meanwhile.",
|
|
3125
|
+
yarn: "YN0016 (\"quarantined\") means a dependency was published less than a day ago. Wait, or add it to `npmPreapprovedPackages` in .yarnrc.yml.",
|
|
3126
|
+
pnpm: "A dependency published too recently is added to `minimumReleaseAgeExclude` in pnpm-workspace.yaml; a skipped build script is approved with `pnpm approve-builds`."
|
|
3127
|
+
};
|
|
3128
|
+
var install = (manager, cwd) => new Promise((resolve) => {
|
|
3129
|
+
const child = spawn(manager, ["install"], {
|
|
3130
|
+
cwd,
|
|
3131
|
+
stdio: "inherit",
|
|
3132
|
+
shell: process.platform === "win32"
|
|
3133
|
+
});
|
|
3134
|
+
child.on("error", () => resolve(false));
|
|
3135
|
+
child.on("close", (code) => resolve(code === 0));
|
|
3136
|
+
});
|
|
3137
|
+
/**
|
|
3138
|
+
* One of the project's own scripts, run quietly: what it prints is the project's business, and whether it worked is
|
|
3139
|
+
* all this needs. `run` is understood by all three managers alike.
|
|
3140
|
+
*/
|
|
3141
|
+
var runScript = (manager, script, cwd) => new Promise((resolve) => {
|
|
3142
|
+
const child = spawn(manager, ["run", script], {
|
|
3143
|
+
cwd,
|
|
3144
|
+
stdio: "ignore",
|
|
3145
|
+
shell: process.platform === "win32"
|
|
3146
|
+
});
|
|
3147
|
+
child.on("error", () => resolve(false));
|
|
3148
|
+
child.on("close", (code) => resolve(code === 0));
|
|
3149
|
+
});
|
|
3150
|
+
/**
|
|
3151
|
+
* How to get to `target` from here, in whichever form is shorter.
|
|
3152
|
+
*
|
|
3153
|
+
* A relative path out of a temp directory is six `../` and unreadable; an absolute one to a sibling folder is noise.
|
|
3154
|
+
* These lines are meant to be pasted, so they are printed the way somebody would have typed them.
|
|
3155
|
+
*/
|
|
3156
|
+
var cdPrefix = (target) => {
|
|
3157
|
+
const relative = path.relative(process.cwd(), target);
|
|
3158
|
+
return target === process.cwd() ? "" : `cd ${relative.length < target.length ? relative : target} && `;
|
|
3159
|
+
};
|
|
3160
|
+
//#endregion
|
|
3161
|
+
//#region src/commands/addPlugin.ts
|
|
3162
|
+
var DEFAULT_NAME$1 = "my-element";
|
|
3163
|
+
/** Where a project keeps its components, most likely first: offered as where the element goes. */
|
|
3164
|
+
var COMPONENT_FOLDERS = [
|
|
3165
|
+
"src/plugins",
|
|
3166
|
+
"src/components",
|
|
3167
|
+
"src/elements"
|
|
3168
|
+
];
|
|
3169
|
+
var splitNames$1 = (list) => list.split(",").map((name) => name.trim()).filter(Boolean);
|
|
3170
|
+
/** What is wrong with the names asked for, if anything: one that makes no element, or two that make the same one. */
|
|
3171
|
+
var namesProblem = (names) => {
|
|
3172
|
+
if (names.length === 0) return "Name at least one element.";
|
|
3173
|
+
for (const name of names) {
|
|
3174
|
+
const problem = pluginNameProblem(name);
|
|
3175
|
+
if (problem) return problem;
|
|
3176
|
+
}
|
|
3177
|
+
const types = names.map((name) => pluginNames(name).type);
|
|
3178
|
+
const repeated = types.find((type, index) => types.indexOf(type) !== index);
|
|
3179
|
+
return repeated ? `Two elements would both be "${repeated}": give each a name of its own.` : void 0;
|
|
3180
|
+
};
|
|
3181
|
+
var isDirectory = async (dir) => {
|
|
3182
|
+
try {
|
|
3183
|
+
return (await fs.stat(dir)).isDirectory();
|
|
3184
|
+
} catch {
|
|
3185
|
+
return false;
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3188
|
+
/** The folders offered: the ones the project already has, and `src/plugins` whatever it has. */
|
|
3189
|
+
var folderOptions$1 = async (project) => {
|
|
3190
|
+
const present = [];
|
|
3191
|
+
for (const folder of COMPONENT_FOLDERS) if (folder === "src/plugins" || await isDirectory(path.join(project.root, folder))) present.push(folder);
|
|
3192
|
+
return present.map((folder) => ({
|
|
3193
|
+
label: folder,
|
|
3194
|
+
value: path.join(project.root, folder)
|
|
3195
|
+
}));
|
|
3196
|
+
};
|
|
3197
|
+
/**
|
|
3198
|
+
* The folder that HOLDS the elements, each of which gets a folder of its own inside it: `--dir` when it was given,
|
|
3199
|
+
* `src/` in a plugin package, `src/plugins` in a project `plitzi create` wrote, and otherwise the one the person picks.
|
|
3200
|
+
*/
|
|
3201
|
+
var holderFolder = async (rl, project, options) => {
|
|
3202
|
+
if (options.dir) return path.resolve(options.dir);
|
|
3203
|
+
if (project.plitzi?.kind === "plugin") return path.join(project.root, "src");
|
|
3204
|
+
if (project.plitzi?.kind === "project") return path.join(project.root, "src/plugins");
|
|
3205
|
+
if (!rl) return;
|
|
3206
|
+
return path.resolve(await askChoice(rl, "Where should it go? (each element gets a folder of its own inside)", await folderOptions$1(project), {
|
|
3207
|
+
label: "Somewhere else",
|
|
3208
|
+
question: "Which folder holds the project’s components? (relative to here)",
|
|
3209
|
+
fallback: "src/plugins"
|
|
3210
|
+
}));
|
|
3211
|
+
};
|
|
3212
|
+
var importPath = (from, target) => `./${path.relative(from, target).split(path.sep).join("/")}`;
|
|
3213
|
+
/** What a project that does not find its plugins by folder has to write, for each way it may render a space. */
|
|
3214
|
+
var registration = (added) => {
|
|
3215
|
+
const entries = (shape) => added.map(({ names, target }) => shape(names, importPath(process.cwd(), target))).join(", ");
|
|
3216
|
+
return [
|
|
3217
|
+
...added.map(({ names, target }) => `import ${names.component} from '${importPath(process.cwd(), target)}';`),
|
|
3218
|
+
"",
|
|
3219
|
+
"// render(), in the browser:",
|
|
3220
|
+
`render('root', options, { ${entries((names) => `${names.type}: { component: ${names.component} }`)} });`,
|
|
3221
|
+
"",
|
|
3222
|
+
"// <PlitziSdk>, in a React application — inside <PlitziSdk>:",
|
|
3223
|
+
...added.map(({ names }) => `<PlitziSdk.Plugin renderType="${names.type}" component={${names.component}} />`),
|
|
3224
|
+
"",
|
|
3225
|
+
"// createServer(), on a page server of your own — and each name in the deployment’s pluginNames:",
|
|
3226
|
+
`plugins: { ${entries((names, from) => `${names.type}: { js: path.resolve('${from}/index.ts'), action: 'compile' }`)} }`
|
|
3227
|
+
].join("\n");
|
|
3228
|
+
};
|
|
3229
|
+
/** The lines a project from before plugins were found by folder adds to the list in its `src/main.ts`. */
|
|
3230
|
+
var mainEntries = (project, added) => project.mode === "server" ? added.map((names) => ` ${names.type}: { js: path.resolve(PROJECT_ROOT, 'src/plugins/${names.component}/index.ts'), action: 'compile' as const, version: '1.0.0' },`).join("\n") : [
|
|
3231
|
+
...added.map((names) => `import ${names.component} from './plugins/${names.component}';`),
|
|
3232
|
+
"",
|
|
3233
|
+
...added.map((names) => ` ${names.type}: { component: ${names.component} },`)
|
|
3234
|
+
].join("\n");
|
|
3235
|
+
/** How to put the elements on a page, in the project they were added to. */
|
|
3236
|
+
var placement = (project, added) => {
|
|
3237
|
+
if (project.source === "cloud") return `In the builder, add a Custom element with the render type ${added.map((names) => `"${names.type}"`).join(", ")}. The builder does not load this project’s components, so it shows “Not Found” there; the pages this project serves render them.`;
|
|
3238
|
+
return `Put them on a page in src/space.ts: ${added.map((names) => `custom({ id: '${names.base}', renderType: '${names.type}' })`).join(", ")}.`;
|
|
3239
|
+
};
|
|
3240
|
+
/** A plugin package's two lists, extended with what was added — or said to be the author's when they were changed. */
|
|
3241
|
+
var listInPackage = async (root, components, added) => {
|
|
3242
|
+
const several = added.length > 1;
|
|
3243
|
+
if (!components) {
|
|
3244
|
+
console.log(chalk.yellow(`
|
|
3245
|
+
src/elements.ts and src/declarations.ts are not the lists the CLI wrote, so they are yours to change: add ${added.map((names) => `${names.component} (and its declaration)`).join(", ")} to both.`));
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
const listed = [...components, ...added.map((names) => names.component)];
|
|
3249
|
+
await writeFiles(root, {
|
|
3250
|
+
"src/elements.ts": elementsRegistry(listed),
|
|
3251
|
+
"src/declarations.ts": declarationsRegistry(listed)
|
|
3252
|
+
});
|
|
3253
|
+
console.log(`\nListed in src/elements.ts and src/declarations.ts: the package publishes ${several ? "them" : "it"} from now on. Look at ${several ? "them" : "it"} in the preview with ${added.map((names) => `element('${names.type}', { id: '${names.base}' })`).join(", ")} in preview/space.ts.`);
|
|
3254
|
+
};
|
|
3255
|
+
/**
|
|
3256
|
+
* A project's `src/plugins/declarations.ts`, extended with what was added — so the space is checked against the new
|
|
3257
|
+
* plugins' events, actions and attributes from its next authoring on. A list somebody changed is theirs to extend.
|
|
3258
|
+
*/
|
|
3259
|
+
var declareInProject = async (project, root, added) => {
|
|
3260
|
+
if (project.source !== "local") return;
|
|
3261
|
+
const { declared } = project;
|
|
3262
|
+
if (!declared) {
|
|
3263
|
+
console.log(chalk.yellow(`
|
|
3264
|
+
src/plugins/declarations.ts is missing or not the list the CLI wrote, so it is yours to change: import ${added.map((names) => `${names.component}/declaration.ts`).join(", ")} there and add ${added.length > 1 ? "them" : "it"} to \`declarations\` — what \`authorSpace(space, { plugins: declarations })\` checks the space against.`));
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
await writeFiles(root, { "src/plugins/declarations.ts": projectDeclarations([...declared, ...added.map((names) => names.component)]) });
|
|
3268
|
+
console.log(`\nDeclared in src/plugins/declarations.ts: flows on ${added.length > 1 ? "their" : "its"} events and the attributes written on ${added.length > 1 ? "them" : "it"} are checked when the space is authored.`);
|
|
3269
|
+
};
|
|
3270
|
+
/** A project the CLI did not write: how to register, and the one dependency the elements import. */
|
|
3271
|
+
var registerElsewhere = async (root, added) => {
|
|
3272
|
+
console.log(`\nRegister ${added.length > 1 ? "them" : "it"} where the project renders its space:\n\n${chalk.dim(registration(added))}`);
|
|
3273
|
+
const { dependencies = {}, devDependencies = {} } = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf-8"));
|
|
3274
|
+
if (!("@plitzi/plitzi-sdk" in {
|
|
3275
|
+
...dependencies,
|
|
3276
|
+
...devDependencies
|
|
3277
|
+
})) console.log(chalk.yellow("\nThe elements import @plitzi/plitzi-sdk, which this project does not depend on yet."));
|
|
3278
|
+
};
|
|
3279
|
+
var addPlugin = async (namesGiven, options) => {
|
|
3280
|
+
const project = await findProject(process.cwd());
|
|
3281
|
+
if (!project) {
|
|
3282
|
+
console.error(chalk.red("plitzi add plugin adds an element to a project, and there is no package.json here or above. For a plugin of its own, run plitzi create <folder> --plugin."));
|
|
3283
|
+
process.exitCode = 1;
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3286
|
+
if (namesGiven.length > 1 && (options.title !== void 0 || options.description !== void 0)) {
|
|
3287
|
+
console.error(chalk.red("--title and --description describe one element: add several without them, or one at a time."));
|
|
3288
|
+
process.exitCode = 1;
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const givenProblem = namesGiven.length > 0 ? namesProblem(namesGiven) : void 0;
|
|
3292
|
+
if (givenProblem) {
|
|
3293
|
+
console.error(chalk.red(givenProblem));
|
|
3294
|
+
process.exitCode = 1;
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const folderKnown = options.dir !== void 0 || project.plitzi !== void 0;
|
|
3298
|
+
const terminal = atTerminal();
|
|
3299
|
+
if (!terminal && (namesGiven.length === 0 || !folderKnown)) {
|
|
3300
|
+
refuseWithoutTerminal("element", [...namesGiven.length === 0 ? [{
|
|
3301
|
+
flag: "<names> (the arguments)",
|
|
3302
|
+
choices: ["<name> …"],
|
|
3303
|
+
question: "What is each element called? e.g. seat-picker."
|
|
3304
|
+
}] : [], ...folderKnown ? [] : [{
|
|
3305
|
+
flag: "--dir",
|
|
3306
|
+
choices: ["<folder>"],
|
|
3307
|
+
question: "Which folder holds the project’s components?"
|
|
3308
|
+
}]]);
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
const rl = terminal ? readline.createInterface({
|
|
3312
|
+
input: process.stdin,
|
|
3313
|
+
output: process.stdout
|
|
3314
|
+
}) : void 0;
|
|
3315
|
+
try {
|
|
3316
|
+
const askedNames = async () => rl ? splitNames$1(await askText(rl, "What is the element called? Several: separate them with commas.", DEFAULT_NAME$1, (reply) => namesProblem(splitNames$1(reply)))) : [DEFAULT_NAME$1];
|
|
3317
|
+
const names = namesGiven.length > 0 ? namesGiven : await askedNames();
|
|
3318
|
+
const holder = await holderFolder(rl, project, options);
|
|
3319
|
+
if (!holder) return;
|
|
3320
|
+
const planned = names.map((name) => {
|
|
3321
|
+
const elementNames = pluginNames(name);
|
|
3322
|
+
return {
|
|
3323
|
+
name,
|
|
3324
|
+
names: elementNames,
|
|
3325
|
+
target: path.join(holder, elementNames.component)
|
|
3326
|
+
};
|
|
3327
|
+
});
|
|
3328
|
+
for (const { target } of planned) if (!options.force && !await isEmpty(target)) {
|
|
3329
|
+
console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
|
|
3330
|
+
process.exitCode = 1;
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
3333
|
+
const single = planned.length === 1;
|
|
3334
|
+
const answer = async (question, given, fallback) => given ?? (rl ? askText(rl, question, fallback) : fallback);
|
|
3335
|
+
const added = [];
|
|
3336
|
+
for (const { name, names: elementNames, target } of planned) {
|
|
3337
|
+
const called = single ? "it" : name;
|
|
3338
|
+
const title = await answer(`What does the builder call ${called}?`, single ? options.title : void 0, elementNames.title);
|
|
3339
|
+
await writeFiles(target, scaffoldElement(name, {
|
|
3340
|
+
title,
|
|
3341
|
+
description: await answer(`What is ${called} for, in a sentence? The builder shows it, and an agent reads it to choose the element.`, single ? options.description : void 0, ""),
|
|
3342
|
+
owner: ""
|
|
3343
|
+
}));
|
|
3344
|
+
added.push({
|
|
3345
|
+
names: {
|
|
3346
|
+
...elementNames,
|
|
3347
|
+
title
|
|
3348
|
+
},
|
|
3349
|
+
target
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
const addedNames = added.map(({ names: elementNames }) => elementNames);
|
|
3353
|
+
const written = added.map(({ names: elementNames, target }) => {
|
|
3354
|
+
return `${path.relative(process.cwd(), target)} — the ${elementNames.type} element`;
|
|
3355
|
+
});
|
|
3356
|
+
console.log(chalk.green(`\n${written.join("\n")}`));
|
|
3357
|
+
const { plitzi } = project;
|
|
3358
|
+
if (plitzi?.kind === "plugin") await listInPackage(project.root, plitzi.components, addedNames);
|
|
3359
|
+
else if (plitzi?.kind === "project" && plitzi.discovers) {
|
|
3360
|
+
console.log(`\nRegistered: src/main.ts finds every folder of src/plugins. ${placement(plitzi, addedNames)}`);
|
|
3361
|
+
await declareInProject(plitzi, project.root, addedNames);
|
|
3362
|
+
} else if (plitzi?.kind === "project") console.log(`
|
|
3363
|
+
This project lists its plugins in src/main.ts (projects created since find them by folder). Add to its \`plugins\`:\n\n${chalk.dim(mainEntries(plitzi, addedNames))}\n\n${placement(plitzi, addedNames)}`);
|
|
3364
|
+
else await registerElsewhere(project.root, added);
|
|
3365
|
+
console.log("");
|
|
3366
|
+
} finally {
|
|
3367
|
+
rl?.close();
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
//#endregion
|
|
3371
|
+
//#region src/commands/create.ts
|
|
3372
|
+
var MODES = ["server", "client"];
|
|
3373
|
+
var SOURCES = ["local", "cloud"];
|
|
3374
|
+
/**
|
|
3375
|
+
* The choices a project is BUILT around, which belong to whoever it is for — never to a default nobody chose.
|
|
3376
|
+
*
|
|
3377
|
+
* Each one is written into the project: the package manager into every command its README, lockfile, Playwright config
|
|
3378
|
+
* and install use; the mode into whether there is a Node tier at all; the source into whether the space is in the repo.
|
|
3379
|
+
* Assumed, they are the wrong project for somebody, and an agent running this was the one assuming. So what was not
|
|
3380
|
+
* passed is asked for when a person is at the terminal, and refused when nobody is — with the exact questions to put
|
|
3381
|
+
* to the person.
|
|
3382
|
+
*/
|
|
3383
|
+
var resolveDecisions = async (options) => {
|
|
3384
|
+
const detected = detectPackageManager();
|
|
3385
|
+
const packageManager = {
|
|
3386
|
+
flag: "--package-manager",
|
|
3387
|
+
choices: PACKAGE_MANAGERS,
|
|
3388
|
+
question: `Which package manager will you work in? (this was run through ${detected})`,
|
|
3389
|
+
given: PACKAGE_MANAGERS.find((manager) => manager === options.packageManager),
|
|
3390
|
+
fallback: detected
|
|
3391
|
+
};
|
|
3392
|
+
const mode = {
|
|
3393
|
+
flag: "--mode",
|
|
3394
|
+
choices: MODES,
|
|
3395
|
+
question: "Should pages render on a Node server of your own (server: SSR + RSC), or only in the browser (client)?",
|
|
3396
|
+
given: MODES.find((candidate) => candidate === options.mode),
|
|
3397
|
+
fallback: "server"
|
|
3398
|
+
};
|
|
3399
|
+
const source = {
|
|
3400
|
+
flag: "--source",
|
|
3401
|
+
choices: SOURCES,
|
|
3402
|
+
question: "Should the space live in the project as code you edit (local), or be read live from your Plitzi account (cloud, which needs a key from Credentials in the builder)?",
|
|
3403
|
+
given: SOURCES.find((candidate) => candidate === options.source),
|
|
3404
|
+
fallback: "local"
|
|
3405
|
+
};
|
|
3406
|
+
const missing = [
|
|
3407
|
+
packageManager,
|
|
3408
|
+
mode,
|
|
3409
|
+
source
|
|
3410
|
+
].filter((question) => question.given === void 0);
|
|
3411
|
+
if (missing.length === 0 || options.yes && atTerminal()) return {
|
|
3412
|
+
packageManager: packageManager.given ?? packageManager.fallback,
|
|
3413
|
+
mode: mode.given ?? mode.fallback,
|
|
3414
|
+
source: source.given ?? source.fallback
|
|
3415
|
+
};
|
|
3416
|
+
if (!atTerminal()) {
|
|
3417
|
+
refuseWithoutTerminal("project", missing);
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
const rl = readline.createInterface({
|
|
3421
|
+
input: process.stdin,
|
|
3422
|
+
output: process.stdout
|
|
3423
|
+
});
|
|
3424
|
+
try {
|
|
3425
|
+
const manager = packageManager.given ?? await ask(rl, packageManager);
|
|
3426
|
+
const tier = manager && (mode.given ?? await ask(rl, mode));
|
|
3427
|
+
const origin = tier && (source.given ?? await ask(rl, source));
|
|
3428
|
+
if (!manager || !tier || !origin) {
|
|
3429
|
+
process.exitCode = 1;
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3432
|
+
return {
|
|
3433
|
+
packageManager: manager,
|
|
3434
|
+
mode: tier,
|
|
3435
|
+
source: origin
|
|
3436
|
+
};
|
|
3437
|
+
} finally {
|
|
3438
|
+
rl.close();
|
|
3439
|
+
}
|
|
3440
|
+
};
|
|
3441
|
+
var keyQuestion = (mode) => `The ${mode === "server" ? "self-hosting" : "public render"} key the project reads the space with (Credentials, in the builder).`;
|
|
3442
|
+
var askForKey = async (mode) => {
|
|
3443
|
+
const rl = readline.createInterface({
|
|
3444
|
+
input: process.stdin,
|
|
3445
|
+
output: process.stdout
|
|
3446
|
+
});
|
|
3447
|
+
try {
|
|
3448
|
+
return (await rl.question(`\n${keyQuestion(mode)}\n> `)).trim();
|
|
3449
|
+
} finally {
|
|
3450
|
+
rl.close();
|
|
3451
|
+
}
|
|
3452
|
+
};
|
|
3453
|
+
var create = async (directory, options) => {
|
|
3454
|
+
const target = path.resolve(directory ?? ".");
|
|
3455
|
+
const name = path.basename(target);
|
|
3456
|
+
const wasEmpty = await isEmpty(target);
|
|
3457
|
+
if (!options.force && !wasEmpty) {
|
|
3458
|
+
console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
|
|
3459
|
+
process.exitCode = 1;
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
const decisions = await resolveDecisions(options);
|
|
3463
|
+
if (!decisions) return;
|
|
3464
|
+
const { packageManager, mode, source } = decisions;
|
|
3465
|
+
if (source === "cloud" && !options.key && !atTerminal()) {
|
|
3466
|
+
refuseWithoutTerminal("project", [{
|
|
3467
|
+
flag: "--key",
|
|
3468
|
+
choices: ["<key>"],
|
|
3469
|
+
question: keyQuestion(mode)
|
|
3470
|
+
}]);
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
const key = source === "cloud" ? options.key ?? await askForKey(mode) : "";
|
|
3474
|
+
if (source === "cloud" && !key) {
|
|
3475
|
+
console.error(chalk.red("\nNo key. Mint one under Credentials in the builder, then run this again."));
|
|
3476
|
+
process.exitCode = 1;
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
await writeFiles(target, scaffold({
|
|
3480
|
+
name,
|
|
3481
|
+
mode,
|
|
3482
|
+
source,
|
|
3483
|
+
key,
|
|
3484
|
+
environment: options.environment ?? "main",
|
|
3485
|
+
packageManager,
|
|
3486
|
+
managerVersion: detectManagerVersion(packageManager, await nearestExisting(target))
|
|
3487
|
+
}));
|
|
3488
|
+
const wantsInstall = options.install !== false;
|
|
3489
|
+
const installed = wantsInstall && await install(packageManager, target);
|
|
3490
|
+
/**
|
|
3491
|
+
* A failed install is a failed command, not a next step.
|
|
3492
|
+
*
|
|
3493
|
+
* The files are already written, so the summary below still prints — but printed on its own after a screen of
|
|
3494
|
+
* resolver errors it reads as success, and a script that ran `create` carries on into a project with no
|
|
3495
|
+
* `node_modules`.
|
|
3496
|
+
*/
|
|
3497
|
+
if (wantsInstall && !installed) {
|
|
3498
|
+
console.error(chalk.red(`\n\`${installCommand(packageManager)}\` failed. The project is written; the reason is in the output above.`));
|
|
3499
|
+
console.error(chalk.dim(INSTALL_HINTS[packageManager]));
|
|
3500
|
+
process.exitCode = 1;
|
|
3501
|
+
}
|
|
3502
|
+
/**
|
|
3503
|
+
* Formatted by the project's own Prettier, once, so the first commit is already in its style — the scaffold writes
|
|
3504
|
+
* tables and lists whose layout depends on what they hold. Only into a folder that was empty: `--force` into one
|
|
3505
|
+
* with work in it must never reformat that work.
|
|
3506
|
+
*/
|
|
3507
|
+
if (installed && wasEmpty) await runScript(packageManager, "format", target);
|
|
3508
|
+
const where = cdPrefix(target);
|
|
3509
|
+
console.log(chalk.green(`\n${name} — ${mode === "server" ? "server-rendered" : "browser-rendered"}, space ${source === "cloud" ? "from Plitzi" : "in the project"}.`));
|
|
3510
|
+
console.log("");
|
|
3511
|
+
if (!installed) console.log(` ${where}${installCommand(packageManager)}`);
|
|
3512
|
+
console.log(` ${where}${runCommand(packageManager, "start")}`);
|
|
3513
|
+
console.log("");
|
|
3514
|
+
console.log(chalk.dim(`.claude/skills carries Plitzi's authoring skill; \`${runCommand(packageManager, "visual")}\` opens the page and checks it.`));
|
|
3515
|
+
if (source === "cloud") console.log(chalk.dim(".env holds the key and is already git-ignored."));
|
|
3516
|
+
console.log("");
|
|
3517
|
+
};
|
|
3518
|
+
//#endregion
|
|
3519
|
+
//#region src/commands/createPlugin.ts
|
|
3520
|
+
var DEFAULT_NAME = "plitzi-plugin-my-element";
|
|
3521
|
+
/** What an agent is told to ask for, and the flags that carry the answers. */
|
|
3522
|
+
var QUESTIONS = {
|
|
3523
|
+
name: {
|
|
3524
|
+
flag: "--name",
|
|
3525
|
+
choices: ["<package name>"],
|
|
3526
|
+
question: "What is the plugin called? Its package name, e.g. plitzi-plugin-seat-picker."
|
|
3527
|
+
},
|
|
3528
|
+
directory: {
|
|
3529
|
+
flag: "<directory> (the first argument)",
|
|
3530
|
+
choices: ["<folder>"],
|
|
3531
|
+
question: "Which folder should the plugin be written to?"
|
|
3532
|
+
}
|
|
3533
|
+
};
|
|
3534
|
+
var splitNames = (list) => list.split(",").map((name) => name.trim()).filter(Boolean);
|
|
3535
|
+
/**
|
|
3536
|
+
* What is wrong with the other elements asked for, if anything: a name that makes no element, or two elements — the
|
|
3537
|
+
* package's own among them — that would share a type, which a space could not tell apart.
|
|
3538
|
+
*/
|
|
3539
|
+
var elementListProblem = (main, others) => {
|
|
3540
|
+
for (const name of others) {
|
|
3541
|
+
const problem = pluginNameProblem(name);
|
|
3542
|
+
if (problem) return problem;
|
|
3543
|
+
}
|
|
3544
|
+
const types = [pluginNames(main).type, ...others.map((name) => pluginNames(name).type)];
|
|
3545
|
+
const repeated = types.find((type, index) => types.indexOf(type) !== index);
|
|
3546
|
+
return repeated ? `Two elements would both be "${repeated}": give each a name of its own.` : void 0;
|
|
3547
|
+
};
|
|
3548
|
+
/** The folders a repository keeps its packages in, as places for this one — or `./<name>` outside of any. */
|
|
3549
|
+
var folderOptions = (project, base) => {
|
|
3550
|
+
if (!project) return [];
|
|
3551
|
+
const root = project.workspaceRoot ?? project.root;
|
|
3552
|
+
return [.../* @__PURE__ */ new Set([...project.workspaceFolders, "plugins"])].map((folder) => {
|
|
3553
|
+
const value = path.join(root, folder, base);
|
|
3554
|
+
return {
|
|
3555
|
+
label: path.relative(process.cwd(), value) || ".",
|
|
3556
|
+
value
|
|
3557
|
+
};
|
|
3558
|
+
});
|
|
3559
|
+
};
|
|
3560
|
+
var askDirectory = async (rl, project, base) => {
|
|
3561
|
+
const typed = {
|
|
3562
|
+
question: "Which folder? (relative to here)",
|
|
3563
|
+
fallback: `./${base}`
|
|
3564
|
+
};
|
|
3565
|
+
if (!project) return path.resolve(await askText(rl, "Which folder should the plugin be written to?", typed.fallback));
|
|
3566
|
+
const chosen = await askChoice(rl, `Which folder of ${path.relative(process.cwd(), project.workspaceRoot ?? project.root) || "this project"} should the plugin go in?`, folderOptions(project, base), {
|
|
3567
|
+
label: "Somewhere else",
|
|
3568
|
+
...typed
|
|
3569
|
+
});
|
|
3570
|
+
return path.resolve(chosen);
|
|
3571
|
+
};
|
|
3572
|
+
/**
|
|
3573
|
+
* The manager: the one the repository already installs with, when it is written into one — asking would only offer a
|
|
3574
|
+
* second lockfile — and otherwise asked, as a project's is.
|
|
3575
|
+
*/
|
|
3576
|
+
var resolveManager = async (rl, options, project) => {
|
|
3577
|
+
const given = PACKAGE_MANAGERS.find((manager) => manager === options.packageManager);
|
|
3578
|
+
if (given ?? project?.packageManager) return given ?? project?.packageManager;
|
|
3579
|
+
const detected = detectPackageManager();
|
|
3580
|
+
if (!rl || options.yes) return options.yes ? detected : void 0;
|
|
3581
|
+
return ask(rl, {
|
|
3582
|
+
flag: "--package-manager",
|
|
3583
|
+
choices: PACKAGE_MANAGERS,
|
|
3584
|
+
question: `Which package manager will you work in? (this was run through ${detected})`,
|
|
3585
|
+
fallback: detected
|
|
3586
|
+
});
|
|
3587
|
+
};
|
|
3588
|
+
var createPlugin = async (directory, options) => {
|
|
3589
|
+
const nameGiven = options.name ?? (directory ? path.basename(path.resolve(directory)) : void 0);
|
|
3590
|
+
const othersGiven = options.elements === void 0 ? void 0 : splitNames(options.elements);
|
|
3591
|
+
const nameProblem = nameGiven === void 0 ? void 0 : pluginNameProblem(nameGiven) ?? elementListProblem(pluginNames(nameGiven).base, othersGiven ?? []);
|
|
3592
|
+
if (nameProblem) {
|
|
3593
|
+
console.error(chalk.red(nameProblem));
|
|
3594
|
+
process.exitCode = 1;
|
|
3595
|
+
return;
|
|
3596
|
+
}
|
|
3597
|
+
const here = await findProject(process.cwd());
|
|
3598
|
+
const terminal = atTerminal();
|
|
3599
|
+
if (!terminal && (nameGiven === void 0 || directory === void 0)) {
|
|
3600
|
+
refuseWithoutTerminal("plugin", [...nameGiven === void 0 ? [QUESTIONS.name] : [], ...directory === void 0 ? [QUESTIONS.directory] : []]);
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
const rl = terminal ? readline.createInterface({
|
|
3604
|
+
input: process.stdin,
|
|
3605
|
+
output: process.stdout
|
|
3606
|
+
}) : void 0;
|
|
3607
|
+
try {
|
|
3608
|
+
/**
|
|
3609
|
+
* Everything the plugin is called and says about itself, asked before a file is written — the package name, what
|
|
3610
|
+
* the builder's catalogue shows, what an agent reads to choose it, who publishes it. Each has a default, taken
|
|
3611
|
+
* whole with Enter; only the name and the folder are ever refused for want of an answer.
|
|
3612
|
+
*/
|
|
3613
|
+
const name = nameGiven ?? (rl ? await askText(rl, QUESTIONS.name.question, DEFAULT_NAME, pluginNameProblem) : DEFAULT_NAME);
|
|
3614
|
+
const names = pluginNames(name);
|
|
3615
|
+
const answer = async (question, given, fallback) => given ?? (rl && !options.yes ? askText(rl, question, fallback) : fallback);
|
|
3616
|
+
const title = await answer("What does the builder call the element?", options.title, names.title);
|
|
3617
|
+
const description = await answer("What is it for, in a sentence? The builder shows it, and an agent reads it to choose the element.", options.description, "");
|
|
3618
|
+
const owner = await answer("Who publishes it?", options.owner, "");
|
|
3619
|
+
const others = othersGiven ?? (rl && !options.yes ? splitNames(await askText(rl, "Any other elements in this package? Their names, separated by commas.", "", (reply) => elementListProblem(names.base, splitNames(reply)))) : []);
|
|
3620
|
+
const otherElements = [];
|
|
3621
|
+
for (const other of others) {
|
|
3622
|
+
const otherNames = pluginNames(other);
|
|
3623
|
+
otherElements.push({
|
|
3624
|
+
name: other,
|
|
3625
|
+
title: await answer(`What does the builder call ${other}?`, void 0, otherNames.title),
|
|
3626
|
+
description: await answer(`What is ${other} for, in a sentence?`, void 0, "")
|
|
3627
|
+
});
|
|
3628
|
+
}
|
|
3629
|
+
const target = directory ? path.resolve(directory) : rl ? await askDirectory(rl, here, names.base) : void 0;
|
|
3630
|
+
if (!target) return;
|
|
3631
|
+
const wasEmpty = await isEmpty(target);
|
|
3632
|
+
if (!options.force && !wasEmpty) {
|
|
3633
|
+
console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
|
|
3634
|
+
process.exitCode = 1;
|
|
3635
|
+
return;
|
|
3636
|
+
}
|
|
3637
|
+
const project = await findProject(path.dirname(target));
|
|
3638
|
+
const packageManager = await resolveManager(rl, options, project);
|
|
3639
|
+
if (!packageManager) {
|
|
3640
|
+
if (!rl) refuseWithoutTerminal("plugin", [{
|
|
3641
|
+
flag: "--package-manager",
|
|
3642
|
+
choices: PACKAGE_MANAGERS,
|
|
3643
|
+
question: "Which package manager will you work in?"
|
|
3644
|
+
}]);
|
|
3645
|
+
process.exitCode = 1;
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
await writeFiles(target, scaffoldPlugin({
|
|
3649
|
+
packageName: name,
|
|
3650
|
+
elements: [{
|
|
3651
|
+
name: names.base,
|
|
3652
|
+
title,
|
|
3653
|
+
description
|
|
3654
|
+
}, ...otherElements],
|
|
3655
|
+
owner,
|
|
3656
|
+
packageManager,
|
|
3657
|
+
managerVersion: detectManagerVersion(packageManager, await nearestExisting(target)),
|
|
3658
|
+
inProject: project !== void 0
|
|
3659
|
+
}));
|
|
3660
|
+
/**
|
|
3661
|
+
* Installed where it will be installed from. Inside a workspace that covers the folder, installing there installs
|
|
3662
|
+
* the workspace; inside a repository that does not list it, Yarn refuses to install a package it does not know —
|
|
3663
|
+
* so that is said, with the fix, rather than failed at.
|
|
3664
|
+
*/
|
|
3665
|
+
const unlisted = project?.workspaceRoot !== void 0 && !coveredByWorkspace(project, target);
|
|
3666
|
+
const wantsInstall = options.install !== false && !unlisted;
|
|
3667
|
+
const installed = wantsInstall && await install(packageManager, target);
|
|
3668
|
+
if (wantsInstall && !installed) {
|
|
3669
|
+
console.error(chalk.red(`\n\`${installCommand(packageManager)}\` failed. The plugin is written; the reason is in the output above.`));
|
|
3670
|
+
console.error(chalk.dim(INSTALL_HINTS[packageManager]));
|
|
3671
|
+
process.exitCode = 1;
|
|
3672
|
+
}
|
|
3673
|
+
if (installed && wasEmpty) await runScript(packageManager, "format", target);
|
|
3674
|
+
const where = cdPrefix(target);
|
|
3675
|
+
console.log(chalk.green(`\n${name} — the ${names.type} element, as a package any space can load.`));
|
|
3676
|
+
if (unlisted) console.log(chalk.yellow(`\n${path.relative(process.cwd(), target)} is not one of the workspace's packages: add it to \`workspaces\`, then install.`));
|
|
3677
|
+
console.log("");
|
|
3678
|
+
if (!installed) console.log(` ${where}${installCommand(packageManager)}`);
|
|
3679
|
+
console.log(` ${where}${runCommand(packageManager, "start")} # the plugin inside a space`);
|
|
3680
|
+
console.log(` ${where}${runCommand(packageManager, "zip")} # what the builder takes, under Resources`);
|
|
3681
|
+
console.log("");
|
|
3682
|
+
} finally {
|
|
3683
|
+
rl?.close();
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
//#endregion
|
|
3687
|
+
//#region ../../packages/sdk-shared/dist/actions/functions.mjs
|
|
3688
|
+
var n = (e) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*\.(ts|js|mjs|json)$/.test(e) && !e.split("/").some((e) => e === ".." || e === "." || e === "");
|
|
3689
|
+
var r = async (e, t) => {
|
|
3690
|
+
let r = {}, i = async (a) => {
|
|
3691
|
+
let o;
|
|
3692
|
+
try {
|
|
3693
|
+
o = await t.list(a ? `${e}/${a}` : e);
|
|
3694
|
+
} catch {
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
for (let s of o) {
|
|
3698
|
+
let o = a ? `${a}/${s.name}` : s.name;
|
|
3699
|
+
s.directory ? s.name !== "node_modules" && !s.name.startsWith(".") && await i(o) : n(o) && (r[o] = await t.read(`${e}/${o}`));
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
return await i(""), r;
|
|
3703
|
+
};
|
|
3704
|
+
//#endregion
|
|
3705
|
+
//#region src/commands/functions.ts
|
|
3706
|
+
var FUNCTIONS_DIR = "functions";
|
|
3707
|
+
var STATE_FILE = path.join(".plitzi", "functions.json");
|
|
3708
|
+
var isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3709
|
+
/** The project's root: where `functions/` and `.plitzi/` go. */
|
|
3710
|
+
var rootOf = async () => (await findProject(process.cwd()))?.root ?? process.cwd();
|
|
3711
|
+
/** Every source file under `functions/`, by its path there — read by the one rule of what a source is. */
|
|
3712
|
+
var readLocal = (root) => r(path.join(root, FUNCTIONS_DIR), {
|
|
3713
|
+
list: async (dir) => (await fs.readdir(dir, { withFileTypes: true })).map((entry) => ({
|
|
3714
|
+
name: entry.name,
|
|
3715
|
+
directory: entry.isDirectory()
|
|
3716
|
+
})),
|
|
3717
|
+
read: (file) => fs.readFile(file, "utf8")
|
|
3718
|
+
});
|
|
3719
|
+
var readState = async (root) => {
|
|
3720
|
+
try {
|
|
3721
|
+
const value = JSON.parse(await fs.readFile(path.join(root, STATE_FILE), "utf8"));
|
|
3722
|
+
if (!isRecord$2(value) || typeof value.space !== "number" || typeof value.version !== "string" || !isRecord$2(value.files)) return;
|
|
3723
|
+
const files = Object.fromEntries(Object.entries(value.files).flatMap(([name, text]) => typeof text === "string" ? [[name, text]] : []));
|
|
3724
|
+
return {
|
|
3725
|
+
space: value.space,
|
|
3726
|
+
version: value.version,
|
|
3727
|
+
files
|
|
3728
|
+
};
|
|
3729
|
+
} catch {
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
};
|
|
3733
|
+
var writeState = async (root, state) => {
|
|
3734
|
+
await fs.mkdir(path.join(root, ".plitzi"), { recursive: true });
|
|
3735
|
+
await fs.writeFile(path.join(root, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
3736
|
+
};
|
|
3737
|
+
/** The files that differ between two copies — added, removed or changed — sorted. */
|
|
3738
|
+
var changedFiles = (a, b) => [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].filter((file) => a[file] !== b[file]).sort();
|
|
3739
|
+
var taskList = (draft) => draft.manifest?.tasks.length ? draft.manifest.tasks.map((task) => `${task.namespace}.${task.action}`).join(", ") : "no tasks";
|
|
3740
|
+
var connect$1 = async (options, doing) => {
|
|
3741
|
+
const api = await apiFor(options);
|
|
3742
|
+
return api ? connectionWithSpace(api, doing) : void 0;
|
|
3743
|
+
};
|
|
3744
|
+
var readDraft = async (connection, spaceId) => {
|
|
3745
|
+
const answered = await authorizedRequest(connection, `/spaces/${spaceId}/functions`);
|
|
3746
|
+
if (!answered.ok) {
|
|
3747
|
+
fail(answered.error);
|
|
3748
|
+
return;
|
|
3749
|
+
}
|
|
3750
|
+
const { reply } = answered.value;
|
|
3751
|
+
if (reply.status !== 200) {
|
|
3752
|
+
fail(reply.data.error ?? `Could not read the space’s functions (${reply.status}).`);
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
return reply.data;
|
|
3756
|
+
};
|
|
3757
|
+
var pullFunctions = async (options) => {
|
|
3758
|
+
const connection = await connect$1(options, "to pull from");
|
|
3759
|
+
if (!connection?.space) return;
|
|
3760
|
+
const root = await rootOf();
|
|
3761
|
+
const [local, state] = await Promise.all([readLocal(root), readState(root)]);
|
|
3762
|
+
const unpushed = state ? changedFiles(local, state.files) : Object.keys(local);
|
|
3763
|
+
if (!options.force && (state?.space ?? connection.space.id) !== connection.space.id) {
|
|
3764
|
+
fail("functions/ is a copy of another space’s. Pull into another project, or pass --force to replace it.");
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3767
|
+
if (!options.force && unpushed.length) {
|
|
3768
|
+
fail(`Pulling would overwrite what is not pushed yet: ${unpushed.join(", ")}.\nPush it first, or pass --force to throw it away.`);
|
|
3769
|
+
return;
|
|
3770
|
+
}
|
|
3771
|
+
const draft = await readDraft(connection, connection.space.id);
|
|
3772
|
+
if (!draft) return;
|
|
3773
|
+
const base = path.join(root, FUNCTIONS_DIR);
|
|
3774
|
+
await Promise.all(Object.keys(local).filter((file) => !Object.hasOwn(draft.files, file)).map((file) => fs.rm(path.join(base, file))));
|
|
3775
|
+
for (const [file, text] of Object.entries(draft.files)) {
|
|
3776
|
+
await fs.mkdir(path.dirname(path.join(base, file)), { recursive: true });
|
|
3777
|
+
await fs.writeFile(path.join(base, file), text);
|
|
3778
|
+
}
|
|
3779
|
+
await writeState(root, {
|
|
3780
|
+
space: connection.space.id,
|
|
3781
|
+
version: draft.version,
|
|
3782
|
+
files: draft.files
|
|
3783
|
+
});
|
|
3784
|
+
const count = Object.keys(draft.files).length;
|
|
3785
|
+
console.log(count ? `Pulled ${count} file${count === 1 ? "" : "s"} of ${chalk.bold(connection.space.name)}’s functions into functions/ — ${taskList(draft)}.` : `${chalk.bold(connection.space.name)} has no functions yet. Write functions/index.ts and plitzi functions push.`);
|
|
3786
|
+
};
|
|
3787
|
+
var printProblems = (problems) => {
|
|
3788
|
+
console.error(chalk.red("The functions were not saved:"));
|
|
3789
|
+
problems.forEach(({ file, line, column, message }) => {
|
|
3790
|
+
const where = file ? `functions/${file}${line ? `:${String(line)}${column ? `:${String(column)}` : ""}` : ""}` : "functions";
|
|
3791
|
+
console.error(` ${chalk.bold(where)} ${message}`);
|
|
3792
|
+
});
|
|
3793
|
+
process.exitCode = 1;
|
|
3794
|
+
};
|
|
3795
|
+
var pushFunctions = async (options) => {
|
|
3796
|
+
const connection = await connect$1(options, "to push to");
|
|
3797
|
+
if (!connection?.space) return;
|
|
3798
|
+
const root = await rootOf();
|
|
3799
|
+
const [local, state] = await Promise.all([readLocal(root), readState(root)]);
|
|
3800
|
+
if (!Object.hasOwn(local, "index.ts")) {
|
|
3801
|
+
fail("There is no functions/index.ts here: it is where a space’s functions start. Pull them, or write it.");
|
|
3802
|
+
return;
|
|
3803
|
+
}
|
|
3804
|
+
let base = state?.space === connection.space.id ? state.version : void 0;
|
|
3805
|
+
if (base === void 0) {
|
|
3806
|
+
const draft = await readDraft(connection, connection.space.id);
|
|
3807
|
+
if (!draft) return;
|
|
3808
|
+
if (Object.keys(draft.files).length) {
|
|
3809
|
+
fail(`${connection.space.name} already has functions. Pull them first: plitzi functions pull.`);
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
base = draft.version;
|
|
3813
|
+
}
|
|
3814
|
+
if (state?.space === connection.space.id && !changedFiles(local, state.files).length) {
|
|
3815
|
+
console.log("Nothing to push: functions/ is what was pulled.");
|
|
3816
|
+
return;
|
|
3817
|
+
}
|
|
3818
|
+
const answered = await authorizedRequest(connection, `/spaces/${connection.space.id}/functions`, {
|
|
3819
|
+
method: "PUT",
|
|
3820
|
+
headers: { "Content-Type": "application/json" },
|
|
3821
|
+
body: JSON.stringify({
|
|
3822
|
+
files: local,
|
|
3823
|
+
base
|
|
3824
|
+
})
|
|
3825
|
+
});
|
|
3826
|
+
if (!answered.ok) {
|
|
3827
|
+
fail(answered.error);
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
const { reply } = answered.value;
|
|
3831
|
+
if (reply.status === 422 && reply.data.problems) {
|
|
3832
|
+
printProblems(reply.data.problems);
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
if (reply.status === 409) {
|
|
3836
|
+
fail(`${connection.space.name}’s functions changed since your pull — in the builder, or from another copy. Keep your changes aside, pull, and apply them again.`);
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
if (reply.status !== 200 || !reply.data.version) {
|
|
3840
|
+
fail(reply.data.refusal?.error ?? reply.data.error ?? `The functions were not saved (${reply.status}).`);
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
await writeState(root, {
|
|
3844
|
+
space: connection.space.id,
|
|
3845
|
+
version: reply.data.version,
|
|
3846
|
+
files: local
|
|
3847
|
+
});
|
|
3848
|
+
console.log(`Pushed to ${chalk.bold(connection.space.name)}’s draft — ${taskList({ manifest: reply.data.manifest ?? null })}. ` + chalk.dim("The live site runs them once the space is published."));
|
|
3849
|
+
};
|
|
3850
|
+
/** What one run of a task did: its logs, then its value — or why it failed, as a failure of the command. */
|
|
3851
|
+
var printRun = (task, report) => {
|
|
3852
|
+
const step = report.steps?.find((entry) => entry.action === task);
|
|
3853
|
+
const took = step ? `${String(step.endTime - step.startTime)} ms` : "";
|
|
3854
|
+
step?.logs?.forEach((line) => console.log(chalk.dim(`log ${line}`)));
|
|
3855
|
+
if (step?.error) {
|
|
3856
|
+
fail(`${task} failed after ${took}: ${step.error}`);
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
console.log(`${chalk.green(report.status ?? "completed")} ${chalk.dim(took ? `in ${took}` : "")}`);
|
|
3860
|
+
console.log(JSON.stringify(report.output?.value ?? null, null, 2));
|
|
3861
|
+
};
|
|
3862
|
+
/** `--params`, read: an object of the task's params by name, or nothing when it is not one. */
|
|
3863
|
+
var paramsOf = (given) => {
|
|
3864
|
+
try {
|
|
3865
|
+
const params = given ? JSON.parse(given) : {};
|
|
3866
|
+
if (isRecord$2(params)) return params;
|
|
3867
|
+
} catch {}
|
|
3868
|
+
fail("--params is a JSON object of the task’s params, by name: --params '{\"name\":\"Ada\"}'");
|
|
3869
|
+
};
|
|
3870
|
+
var tryFunction = async (task, options) => {
|
|
3871
|
+
const params = paramsOf(options.params);
|
|
3872
|
+
if (!params) return;
|
|
3873
|
+
const connection = await connect$1(options, "to try it in");
|
|
3874
|
+
if (!connection?.space) return;
|
|
3875
|
+
const answered = await authorizedRequest(connection, `/spaces/${connection.space.id}/functions/try`, {
|
|
3876
|
+
method: "POST",
|
|
3877
|
+
headers: { "Content-Type": "application/json" },
|
|
3878
|
+
body: JSON.stringify({
|
|
3879
|
+
task,
|
|
3880
|
+
params
|
|
3881
|
+
})
|
|
3882
|
+
});
|
|
3883
|
+
if (!answered.ok) {
|
|
3884
|
+
fail(answered.error);
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
const { reply } = answered.value;
|
|
3888
|
+
if (reply.status !== 200) {
|
|
3889
|
+
fail(reply.data.error ?? `The task could not be tried (${reply.status}).`);
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
printRun(task, reply.data);
|
|
3893
|
+
};
|
|
3894
|
+
var isLocalRunner = (value) => isRecord$2(value) && typeof value.createLocalFunctions === "function";
|
|
3895
|
+
/**
|
|
3896
|
+
* This project's own runner: `dev` runs the functions with the `@plitzi/sdk-server` the project installed, so the build,
|
|
3897
|
+
* the checks, the isolates and the limits are the platform's own, at the version the project is on.
|
|
3898
|
+
*/
|
|
3899
|
+
var projectRunner = async (root) => {
|
|
3900
|
+
try {
|
|
3901
|
+
const entry = createRequire(path.join(root, "package.json")).resolve("@plitzi/sdk-server/functions-runner");
|
|
3902
|
+
const loaded = await import(pathToFileURL(entry).href);
|
|
3903
|
+
if (isLocalRunner(loaded)) return loaded;
|
|
3904
|
+
} catch {}
|
|
3905
|
+
fail("plitzi functions dev runs them with this project’s own @plitzi/sdk-server, and the isolates it needs:\n npm install --save-dev @plitzi/sdk-server isolated-vm core-js");
|
|
3906
|
+
};
|
|
3907
|
+
/** `PLITZI_FUNCTIONS_CREDENTIALS`, a JSON object of credential id → its keys: what `ctx.fetch` may name locally. */
|
|
3908
|
+
var localCredentials = () => {
|
|
3909
|
+
const raw = process.env.PLITZI_FUNCTIONS_CREDENTIALS;
|
|
3910
|
+
try {
|
|
3911
|
+
const value = raw ? JSON.parse(raw) : {};
|
|
3912
|
+
return isRecord$2(value) ? Object.fromEntries(Object.entries(value).flatMap(([id, keys]) => isRecord$2(keys) ? [[id, Object.fromEntries(Object.entries(keys).filter((pair) => typeof pair[1] === "string"))]] : [])) : {};
|
|
3913
|
+
} catch {
|
|
3914
|
+
return {};
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
var NO_NODE_SNAPSHOT = "--no-node-snapshot";
|
|
3918
|
+
/**
|
|
3919
|
+
* The isolates `dev` runs need Node's startup snapshot off, and a process cannot turn it off once started: `dev` runs
|
|
3920
|
+
* itself again with it, and answers with that run's exit code.
|
|
3921
|
+
*/
|
|
3922
|
+
var rerunWithoutNodeSnapshot = () => new Promise((resolve, reject) => {
|
|
3923
|
+
const child = spawn(process.execPath, [
|
|
3924
|
+
...process.execArgv,
|
|
3925
|
+
NO_NODE_SNAPSHOT,
|
|
3926
|
+
...process.argv.slice(1)
|
|
3927
|
+
], { stdio: "inherit" });
|
|
3928
|
+
child.once("error", reject);
|
|
3929
|
+
child.once("exit", (code) => {
|
|
3930
|
+
resolve(code ?? 1);
|
|
3931
|
+
});
|
|
1203
3932
|
});
|
|
3933
|
+
/**
|
|
3934
|
+
* `plitzi functions dev <task>`: the task run from `functions/` on this machine, as the platform would run it — and,
|
|
3935
|
+
* with `--watch`, again every time a file is saved. Nothing reaches the space: its draft is what `push` sends.
|
|
3936
|
+
*/
|
|
3937
|
+
var devFunction = async (task, options) => {
|
|
3938
|
+
if (!process.execArgv.includes(NO_NODE_SNAPSHOT)) {
|
|
3939
|
+
process.exitCode = await rerunWithoutNodeSnapshot();
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3942
|
+
const params = paramsOf(options.params);
|
|
3943
|
+
const root = await rootOf();
|
|
3944
|
+
const runner = params ? await projectRunner(root) : void 0;
|
|
3945
|
+
if (!params || !runner) return;
|
|
3946
|
+
const local = runner.createLocalFunctions({ credentials: localCredentials() });
|
|
3947
|
+
const run = async () => {
|
|
3948
|
+
const loaded = await local.load(await readLocal(root));
|
|
3949
|
+
if (!loaded.ok) {
|
|
3950
|
+
printProblems(loaded.problems);
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
if (!loaded.tasks.includes(task)) {
|
|
3954
|
+
fail(`functions/ declares no task ${task}. It declares: ${loaded.tasks.join(", ") || "none"}.`);
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
printRun(task, await local.tryTask(task, params));
|
|
3958
|
+
};
|
|
3959
|
+
await run();
|
|
3960
|
+
if (!options.watch) return;
|
|
3961
|
+
console.log(chalk.dim("\nWatching functions/ — every save runs it again. ^C to stop."));
|
|
3962
|
+
let pending;
|
|
3963
|
+
watch(path.join(root, FUNCTIONS_DIR), { recursive: true }, () => {
|
|
3964
|
+
clearTimeout(pending);
|
|
3965
|
+
pending = setTimeout(() => {
|
|
3966
|
+
process.exitCode = void 0;
|
|
3967
|
+
console.log(chalk.dim(`\n— ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`));
|
|
3968
|
+
run();
|
|
3969
|
+
}, 150);
|
|
3970
|
+
});
|
|
3971
|
+
};
|
|
1204
3972
|
//#endregion
|
|
1205
|
-
//#region src/
|
|
1206
|
-
var
|
|
1207
|
-
var SOURCES = ["local", "cloud"];
|
|
1208
|
-
/** A directory that does not exist yet is as empty as one can be, which is the answer this question wants. */
|
|
1209
|
-
var isEmpty = async (target) => {
|
|
3973
|
+
//#region src/pack/typeDeclarations.ts
|
|
3974
|
+
var loadTypeScript = (root) => {
|
|
1210
3975
|
try {
|
|
1211
|
-
return (
|
|
3976
|
+
return createRequire(path.join(root, "package.json"))("typescript");
|
|
1212
3977
|
} catch {
|
|
1213
|
-
return
|
|
3978
|
+
return;
|
|
1214
3979
|
}
|
|
1215
3980
|
};
|
|
1216
|
-
var
|
|
3981
|
+
var emitTypeDeclarations = (root, outDir) => {
|
|
3982
|
+
const ts = loadTypeScript(root);
|
|
3983
|
+
if (!ts) return "no-typescript";
|
|
3984
|
+
const configFile = ts.findConfigFile(root, (file) => ts.sys.fileExists(file));
|
|
3985
|
+
const config = configFile ? ts.readConfigFile(configFile, (file) => ts.sys.readFile(file)).config : {};
|
|
3986
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, root);
|
|
3987
|
+
const source = path.join(root, "src");
|
|
3988
|
+
const files = parsed.fileNames.filter((file) => file.startsWith(`${source}${path.sep}`));
|
|
3989
|
+
const { diagnostics } = ts.createProgram(files, {
|
|
3990
|
+
...parsed.options,
|
|
3991
|
+
noEmit: false,
|
|
3992
|
+
declaration: true,
|
|
3993
|
+
emitDeclarationOnly: true,
|
|
3994
|
+
outDir: path.join(outDir, "types"),
|
|
3995
|
+
rootDir: source
|
|
3996
|
+
}).emit();
|
|
3997
|
+
if (diagnostics.length > 0) {
|
|
3998
|
+
const messages = diagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
|
3999
|
+
throw new Error(messages.join("\n"));
|
|
4000
|
+
}
|
|
4001
|
+
return "written";
|
|
4002
|
+
};
|
|
4003
|
+
//#endregion
|
|
4004
|
+
//#region src/pack/pack.ts
|
|
4005
|
+
/**
|
|
4006
|
+
* A plugin built into what a space loads: one ES module, the `plugin-manifest.json` that describes it, and the zip the
|
|
4007
|
+
* builder takes under Resources — from the elements of a plugin package, or from element folders of any project.
|
|
4008
|
+
*
|
|
4009
|
+
* One implementation, here, rather than a build copied into every package: a copy is what went stale in the template
|
|
4010
|
+
* this replaced, and the contract it has to keep is the platform's, which moves with the CLI's releases.
|
|
4011
|
+
*
|
|
4012
|
+
* The contract, enforced rather than described:
|
|
4013
|
+
* - React and the SDK are the page's. They stay out of the bundle and are imported from the page — a second React is
|
|
4014
|
+
* "Invalid hook call" and a blank element; a second SDK is an element that cannot see the space it is in.
|
|
4015
|
+
* - One file. A page imports the plugin from a blob URL, where a second chunk has nowhere to be found; images and fonts
|
|
4016
|
+
* travel inside it for the same reason.
|
|
4017
|
+
* - The manifest describes what was built: each element's declaration, and each file with the hash it has.
|
|
4018
|
+
*/
|
|
4019
|
+
/** What the page provides, and the plugin imports rather than carries. */
|
|
4020
|
+
var EXTERNAL = [
|
|
4021
|
+
"react",
|
|
4022
|
+
"react-dom",
|
|
4023
|
+
"react/*",
|
|
4024
|
+
"react-dom/*",
|
|
4025
|
+
"@plitzi/plitzi-sdk",
|
|
4026
|
+
"@plitzi/plitzi-sdk/*"
|
|
4027
|
+
];
|
|
4028
|
+
/** Kept inside the one file: a path relative to a blob URL points nowhere. */
|
|
4029
|
+
var INLINED = [
|
|
4030
|
+
".png",
|
|
4031
|
+
".jpg",
|
|
4032
|
+
".jpeg",
|
|
4033
|
+
".gif",
|
|
4034
|
+
".svg",
|
|
4035
|
+
".webp",
|
|
4036
|
+
".avif",
|
|
4037
|
+
".woff",
|
|
4038
|
+
".woff2",
|
|
4039
|
+
".ttf",
|
|
4040
|
+
".otf"
|
|
4041
|
+
];
|
|
4042
|
+
/** A plugin that cannot be packed, with what to change — never a stack trace out of esbuild. */
|
|
4043
|
+
var PackError = class extends Error {};
|
|
4044
|
+
var isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4045
|
+
/** The declaration, or what is missing from it — named, so the fix is one edit away. */
|
|
4046
|
+
var checkDeclaration = (value, where) => {
|
|
4047
|
+
const missing = (field) => new PackError(`${where}: the declaration has no ${field}. Write it the way \`plitzi add plugin\` does.`);
|
|
4048
|
+
if (!isRecord$1(value) || typeof value.type !== "string" || !value.type) throw missing("`type`");
|
|
4049
|
+
const { content } = value;
|
|
4050
|
+
if (!isRecord$1(content)) throw missing("`content`");
|
|
4051
|
+
for (const field of [
|
|
4052
|
+
"attributes",
|
|
4053
|
+
"definition",
|
|
4054
|
+
"builder",
|
|
4055
|
+
"market",
|
|
4056
|
+
"defaultStyle"
|
|
4057
|
+
]) if (!isRecord$1(content[field])) throw missing(`\`content.${field}\``);
|
|
4058
|
+
if (!isRecord$1(content.definition) || typeof content.definition.label !== "string") throw missing("`content.definition.label`");
|
|
4059
|
+
return value;
|
|
4060
|
+
};
|
|
4061
|
+
var toImportPath = (file) => file.split(path.sep).join("/");
|
|
4062
|
+
/** Runs a module esbuild produced, in this process: bundled, it imports nothing it would have to resolve. */
|
|
4063
|
+
var evaluate = async (code) => await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
|
|
4064
|
+
/**
|
|
4065
|
+
* The declarations, read the way the build reads the rest: bundled by esbuild, then run. A declaration is data — it
|
|
4066
|
+
* imports types at most — so running it loads no React and touches no DOM.
|
|
4067
|
+
*/
|
|
4068
|
+
var readDeclarations = async (root, source) => {
|
|
4069
|
+
const contents = source.kind === "package" ? `export { declarations } from '${toImportPath(source.declarations)}';` : [...source.folders.map((folder, index) => `import d${index} from '${toImportPath(path.join(folder, "declaration.ts"))}';`), `export const declarations = [${source.folders.map((_folder, index) => `d${index}`).join(", ")}];`].join("\n");
|
|
4070
|
+
const { declarations } = await evaluate((await build({
|
|
4071
|
+
stdin: {
|
|
4072
|
+
contents,
|
|
4073
|
+
resolveDir: root,
|
|
4074
|
+
sourcefile: "plitzi-declarations.ts",
|
|
4075
|
+
loader: "ts"
|
|
4076
|
+
},
|
|
4077
|
+
bundle: true,
|
|
4078
|
+
write: false,
|
|
4079
|
+
format: "esm",
|
|
4080
|
+
platform: "neutral",
|
|
4081
|
+
external: EXTERNAL,
|
|
4082
|
+
logLevel: "silent"
|
|
4083
|
+
}).catch((error) => {
|
|
4084
|
+
throw new PackError(`The declarations could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
4085
|
+
})).outputFiles[0].text);
|
|
4086
|
+
if (!Array.isArray(declarations) || declarations.length === 0) throw new PackError("No element to pack: the list of declarations is empty.");
|
|
4087
|
+
const where = (index) => source.kind === "package" ? `${path.relative(root, source.declarations)}[${index}]` : path.relative(root, source.folders[index]);
|
|
4088
|
+
const checked = declarations.map((declaration, index) => checkDeclaration(declaration, where(index)));
|
|
4089
|
+
const types = checked.map((declaration) => declaration.type);
|
|
4090
|
+
const repeated = types.find((type, index) => types.indexOf(type) !== index);
|
|
4091
|
+
if (repeated) throw new PackError(`Two elements are both "${repeated}": a space could not tell them apart.`);
|
|
4092
|
+
return checked;
|
|
4093
|
+
};
|
|
4094
|
+
/** The entry a space loads: the package's own, or one written for the folders — the first the plugin, the rest its `plugins`. */
|
|
4095
|
+
var entryOf = (source, root) => source.kind === "package" ? { entryPoints: [source.entry] } : { stdin: {
|
|
4096
|
+
resolveDir: root,
|
|
4097
|
+
contents: [
|
|
4098
|
+
...source.folders.map((folder, index) => `import e${index} from '${toImportPath(path.join(folder, "index.ts"))}';`),
|
|
4099
|
+
"export default e0;",
|
|
4100
|
+
`export const plugins = { ${source.folders.slice(1).map((_folder, index) => `[e${index + 1}.type]: e${index + 1}`).join(", ")} };`,
|
|
4101
|
+
`export const elements = [${source.folders.map((_folder, index) => `e${index}`).join(", ")}];`
|
|
4102
|
+
].join("\n"),
|
|
4103
|
+
sourcefile: "plitzi-plugin.ts",
|
|
4104
|
+
loader: "ts"
|
|
4105
|
+
} };
|
|
4106
|
+
var writeTypes = (root, outDir) => {
|
|
1217
4107
|
try {
|
|
1218
|
-
return (
|
|
4108
|
+
return emitTypeDeclarations(root, outDir);
|
|
4109
|
+
} catch (error) {
|
|
4110
|
+
throw new PackError(`The type declarations could not be written: ${error instanceof Error ? error.message : String(error)}`);
|
|
4111
|
+
}
|
|
4112
|
+
};
|
|
4113
|
+
var integrityOf = async (file) => {
|
|
4114
|
+
const content = await fs.readFile(file);
|
|
4115
|
+
return `sha384-${createHash("sha384").update(content).digest("base64")}`;
|
|
4116
|
+
};
|
|
4117
|
+
var packPlugin = async ({ root, source, base, version, outDir, zip, types = false }) => {
|
|
4118
|
+
const relative = path.relative(root, outDir);
|
|
4119
|
+
if (relative.startsWith("..") || path.isAbsolute(relative) || relative === "") throw new PackError(`${outDir} is not a folder inside ${root}: the build empties it first, so it has to be.`);
|
|
4120
|
+
if (source.kind === "elements") for (const folder of source.folders) for (const file of ["index.ts", "declaration.ts"]) try {
|
|
4121
|
+
await fs.access(path.join(folder, file));
|
|
4122
|
+
} catch {
|
|
4123
|
+
throw new PackError(`${path.relative(root, folder) || folder} has no ${file}: an element is a folder holding its component, declaration and index. \`plitzi add plugin\` writes one.`);
|
|
4124
|
+
}
|
|
4125
|
+
const declarations = await readDeclarations(root, source);
|
|
4126
|
+
await fs.rm(outDir, {
|
|
4127
|
+
recursive: true,
|
|
4128
|
+
force: true
|
|
4129
|
+
});
|
|
4130
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
4131
|
+
await build({
|
|
4132
|
+
...entryOf(source, root),
|
|
4133
|
+
bundle: true,
|
|
4134
|
+
format: "esm",
|
|
4135
|
+
platform: "browser",
|
|
4136
|
+
target: "es2022",
|
|
4137
|
+
jsx: "automatic",
|
|
4138
|
+
minify: true,
|
|
4139
|
+
legalComments: "none",
|
|
4140
|
+
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
4141
|
+
external: EXTERNAL,
|
|
4142
|
+
loader: Object.fromEntries(INLINED.map((extension) => [extension, "dataurl"])),
|
|
4143
|
+
outfile: path.join(outDir, `${base}.mjs`),
|
|
4144
|
+
logLevel: "silent"
|
|
4145
|
+
}).catch((error) => {
|
|
4146
|
+
throw new PackError(`The plugin could not be built: ${error instanceof Error ? error.message : String(error)}`);
|
|
4147
|
+
});
|
|
4148
|
+
const built = (await fs.readdir(outDir)).filter((file) => file.endsWith(".mjs") || file.endsWith(".css")).sort();
|
|
4149
|
+
const assets = Object.fromEntries(await Promise.all(built.map(async (file) => [file, {
|
|
4150
|
+
src: file,
|
|
4151
|
+
type: file.endsWith(".css") ? "style" : "script",
|
|
4152
|
+
integrity: await integrityOf(path.join(outDir, file)),
|
|
4153
|
+
isMain: true
|
|
4154
|
+
}])));
|
|
4155
|
+
const [main] = declarations;
|
|
4156
|
+
const { definition, market } = main.content;
|
|
4157
|
+
const manifest = {
|
|
4158
|
+
root: main.type,
|
|
4159
|
+
version,
|
|
4160
|
+
author: typeof market.owner === "string" ? market.owner : "",
|
|
4161
|
+
definition: {
|
|
4162
|
+
name: definition.label,
|
|
4163
|
+
description: definition.description ?? "",
|
|
4164
|
+
...market,
|
|
4165
|
+
verified: false
|
|
4166
|
+
},
|
|
4167
|
+
pluginSchema: Object.fromEntries(declarations.map(({ type, content: { attributes, definition: elementDefinition, builder, defaultStyle } }) => [type, {
|
|
4168
|
+
attributes,
|
|
4169
|
+
definition: elementDefinition,
|
|
4170
|
+
builder,
|
|
4171
|
+
defaultStyle
|
|
4172
|
+
}])),
|
|
4173
|
+
assets,
|
|
4174
|
+
assetsSettings: {}
|
|
4175
|
+
};
|
|
4176
|
+
await fs.writeFile(path.join(outDir, "plugin-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
4177
|
+
const files = [...built, "plugin-manifest.json"];
|
|
4178
|
+
if (zip) {
|
|
4179
|
+
const entries = await Promise.all(files.map(async (file) => [file, await fs.readFile(path.join(outDir, file))]));
|
|
4180
|
+
await fs.mkdir(path.dirname(zip), { recursive: true });
|
|
4181
|
+
await fs.writeFile(zip, zipSync(Object.fromEntries(entries)));
|
|
4182
|
+
}
|
|
4183
|
+
const typesOutcome = types ? writeTypes(root, outDir) : void 0;
|
|
4184
|
+
return {
|
|
4185
|
+
root: main.type,
|
|
4186
|
+
types: declarations.map((declaration) => declaration.type),
|
|
4187
|
+
files,
|
|
4188
|
+
zip,
|
|
4189
|
+
typesOutcome
|
|
4190
|
+
};
|
|
4191
|
+
};
|
|
4192
|
+
//#endregion
|
|
4193
|
+
//#region src/commands/packPlugin.ts
|
|
4194
|
+
var hasElement = async (folder) => {
|
|
4195
|
+
try {
|
|
4196
|
+
await Promise.all(["index.ts", "declaration.ts"].map((file) => fs.access(path.join(folder, file))));
|
|
4197
|
+
return true;
|
|
1219
4198
|
} catch {
|
|
1220
4199
|
return false;
|
|
1221
4200
|
}
|
|
1222
4201
|
};
|
|
1223
|
-
/** The
|
|
1224
|
-
var
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
4202
|
+
/** The element folders a project keeps in `src/plugins` — the ones with a declaration, which is what packs. */
|
|
4203
|
+
var elementFolders = async (project) => {
|
|
4204
|
+
const dir = path.join(project.root, "src/plugins");
|
|
4205
|
+
let entries = [];
|
|
4206
|
+
try {
|
|
4207
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
4208
|
+
} catch {
|
|
4209
|
+
return [];
|
|
4210
|
+
}
|
|
4211
|
+
const folders = entries.map((name) => path.join(dir, name));
|
|
4212
|
+
const packable = await Promise.all(folders.map(hasElement));
|
|
4213
|
+
return folders.filter((_folder, index) => packable[index]);
|
|
4214
|
+
};
|
|
4215
|
+
var readVersion = async (root) => {
|
|
4216
|
+
const { version } = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf-8"));
|
|
4217
|
+
return version ?? "0.0.0";
|
|
4218
|
+
};
|
|
4219
|
+
/** The `-kebab-` spelling of a type, for the files it names: `seatPicker` writes `seat-picker.mjs`. */
|
|
4220
|
+
var fileNameOf = (folder) => path.basename(folder).replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
4221
|
+
/** Which folders to pack, when none were named: asked for at a terminal, from the ones the project has. */
|
|
4222
|
+
var askFolders = async (candidates, root) => {
|
|
4223
|
+
const rl = readline.createInterface({
|
|
4224
|
+
input: process.stdin,
|
|
4225
|
+
output: process.stdout
|
|
4226
|
+
});
|
|
4227
|
+
try {
|
|
4228
|
+
return (await askText(rl, `Which elements go in the plugin? The first is the one it is named after.\n${candidates.map((folder, index) => ` ${index + 1}. ${path.relative(root, folder)}`).join("\n")}\nTheir numbers, separated by commas:`, "1", (answer) => answer.split(",").every((part) => {
|
|
4229
|
+
const index = Number(part.trim()) - 1;
|
|
4230
|
+
return Number.isInteger(index) && index >= 0 && index < candidates.length;
|
|
4231
|
+
}) ? void 0 : `Numbers from 1 to ${candidates.length}, separated by commas.`)).split(",").map((part) => candidates[Number(part.trim()) - 1]);
|
|
4232
|
+
} finally {
|
|
4233
|
+
rl.close();
|
|
4234
|
+
}
|
|
1228
4235
|
};
|
|
1229
4236
|
/**
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
* The scaffold already exempts what it ships, but not the rest of the tree: a third-party dependency published in
|
|
1233
|
-
* the last day is held back by the same age gates, and says so in a code that means nothing to somebody who has
|
|
1234
|
-
* not touched the project yet.
|
|
4237
|
+
* The element folders to pack, when this is not a plugin package packing itself: those named, or else the ones the
|
|
4238
|
+
* project keeps in `src/plugins`, asked for at a terminal. `undefined` when there is nothing to pack or nobody to ask.
|
|
1235
4239
|
*/
|
|
1236
|
-
var
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
4240
|
+
var chosenFolders = async (project, foldersGiven) => {
|
|
4241
|
+
if (foldersGiven.length > 0) return foldersGiven.map((folder) => path.resolve(folder));
|
|
4242
|
+
const candidates = await elementFolders(project);
|
|
4243
|
+
if (candidates.length === 0) {
|
|
4244
|
+
console.error(chalk.red("No element to pack: name its folder (plitzi pack plugin src/components/SeatPicker), or add one with plitzi add plugin."));
|
|
4245
|
+
process.exitCode = 1;
|
|
4246
|
+
return;
|
|
4247
|
+
}
|
|
4248
|
+
if (!atTerminal()) {
|
|
4249
|
+
refuseWithoutTerminal("plugin", [{
|
|
4250
|
+
flag: "<folders> (the arguments)",
|
|
4251
|
+
choices: candidates.map((folder) => path.relative(process.cwd(), folder)),
|
|
4252
|
+
question: "Which elements go in the plugin? The first is the one it is named after."
|
|
4253
|
+
}], "plitzi pack plugin stopped before building anything: which elements go in it shape the whole plugin");
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
4256
|
+
return askFolders(candidates, project.root);
|
|
1240
4257
|
};
|
|
1241
|
-
var
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
4258
|
+
var packPluginCommand = async (foldersGiven, options) => {
|
|
4259
|
+
const project = await findProject(process.cwd());
|
|
4260
|
+
if (!project) {
|
|
4261
|
+
console.error(chalk.red("plitzi pack plugin packs a plugin of a project, and there is no package.json here or above."));
|
|
4262
|
+
process.exitCode = 1;
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
const inPackage = project.plitzi?.kind === "plugin" && foldersGiven.length === 0;
|
|
4266
|
+
const folders = inPackage ? [] : await chosenFolders(project, foldersGiven);
|
|
4267
|
+
if (!folders) return;
|
|
4268
|
+
const packageName = JSON.parse(await fs.readFile(path.join(project.root, "package.json"), "utf-8"));
|
|
4269
|
+
const base = inPackage ? pluginNames(packageName.name ?? path.basename(project.root)).base : fileNameOf(folders[0]);
|
|
4270
|
+
const version = options.pluginVersion ?? await readVersion(project.root);
|
|
4271
|
+
const source = inPackage ? {
|
|
4272
|
+
kind: "package",
|
|
4273
|
+
entry: path.join(project.root, "src/index.ts"),
|
|
4274
|
+
declarations: path.join(project.root, "src/declarations.ts")
|
|
4275
|
+
} : {
|
|
4276
|
+
kind: "elements",
|
|
4277
|
+
folders
|
|
4278
|
+
};
|
|
4279
|
+
const outDir = options.out ? path.resolve(options.out) : path.join(project.root, inPackage ? "dist" : `dist/plugins/${base}`);
|
|
4280
|
+
const zip = options.zip === false ? void 0 : path.join(inPackage ? project.root : path.dirname(outDir), `${base}-${version}.zip`);
|
|
4281
|
+
try {
|
|
4282
|
+
const result = await packPlugin({
|
|
4283
|
+
root: project.root,
|
|
4284
|
+
source,
|
|
4285
|
+
base,
|
|
4286
|
+
version,
|
|
4287
|
+
outDir,
|
|
4288
|
+
zip,
|
|
4289
|
+
types: inPackage
|
|
4290
|
+
});
|
|
4291
|
+
console.log(chalk.green(`\n${result.types.join(", ")} — packed at ${path.relative(process.cwd(), outDir) || "."}`));
|
|
4292
|
+
for (const file of result.files) console.log(chalk.dim(` ${file}`));
|
|
4293
|
+
if (result.typesOutcome === "written") console.log(chalk.dim(" types/"));
|
|
4294
|
+
else if (result.typesOutcome === "no-typescript") console.log(chalk.yellow(" No type declarations: the package has no TypeScript installed to write them with."));
|
|
4295
|
+
if (result.zip) {
|
|
4296
|
+
console.log(`\n ${path.relative(process.cwd(), result.zip)}`);
|
|
4297
|
+
console.log(chalk.dim(" Upload it in the builder under Resources, as a plugin."));
|
|
4298
|
+
}
|
|
4299
|
+
console.log("");
|
|
4300
|
+
} catch (error) {
|
|
4301
|
+
if (!(error instanceof PackError)) throw error;
|
|
4302
|
+
console.error(chalk.red(error.message));
|
|
4303
|
+
process.exitCode = 1;
|
|
4304
|
+
}
|
|
4305
|
+
};
|
|
4306
|
+
//#endregion
|
|
4307
|
+
//#region src/commands/runtime.ts
|
|
4308
|
+
var DEFAULT_ENTRY = path.join("src", "runtime.ts");
|
|
4309
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4310
|
+
var isPacker = (value) => isRecord(value) && typeof value.packRuntime === "function";
|
|
4311
|
+
/** This project's own packer — read, since it is the project's. */
|
|
4312
|
+
var projectPacker = async (root) => {
|
|
4313
|
+
try {
|
|
4314
|
+
const entry = createRequire(path.join(root, "package.json")).resolve("@plitzi/sdk-server/runtime");
|
|
4315
|
+
const loaded = await import(pathToFileURL(entry).href);
|
|
4316
|
+
if (isPacker(loaded)) return loaded;
|
|
4317
|
+
} catch {}
|
|
4318
|
+
fail("plitzi runtime packs it with this project’s own @plitzi/sdk-server:\n npm install @plitzi/sdk-server");
|
|
4319
|
+
};
|
|
4320
|
+
var connect = async (options, doing) => {
|
|
4321
|
+
const api = await apiFor(options);
|
|
4322
|
+
return api ? connectionWithSpace(api, doing) : void 0;
|
|
4323
|
+
};
|
|
4324
|
+
/** Packs the project's runtime module and keeps it as the space's draft runtime — which a publish takes live. */
|
|
4325
|
+
var pushRuntime = async (options) => {
|
|
4326
|
+
const connection = await connect(options, "to push to");
|
|
4327
|
+
if (!connection?.space) return;
|
|
4328
|
+
const root = (await findProject(process.cwd()))?.root ?? process.cwd();
|
|
4329
|
+
const entry = path.resolve(root, options.entry ?? DEFAULT_ENTRY);
|
|
4330
|
+
try {
|
|
4331
|
+
await fs.access(entry);
|
|
4332
|
+
} catch {
|
|
4333
|
+
fail(`There is no ${path.relative(root, entry)}: the module whose default export is defineRuntime({ start }).`);
|
|
4334
|
+
return;
|
|
4335
|
+
}
|
|
4336
|
+
const packer = await projectPacker(root);
|
|
4337
|
+
if (!packer) return;
|
|
4338
|
+
let bytes;
|
|
4339
|
+
try {
|
|
4340
|
+
bytes = await packer.packRuntime(entry);
|
|
4341
|
+
} catch (error) {
|
|
4342
|
+
fail(`It does not pack: ${error instanceof Error ? error.message : String(error)}`);
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime`, {
|
|
4346
|
+
method: "PUT",
|
|
4347
|
+
headers: { "content-type": "application/octet-stream" },
|
|
4348
|
+
body: new Uint8Array(bytes)
|
|
1246
4349
|
});
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
4350
|
+
if (!answered.ok) {
|
|
4351
|
+
fail(answered.error);
|
|
4352
|
+
return;
|
|
4353
|
+
}
|
|
4354
|
+
const { reply } = answered.value;
|
|
4355
|
+
if (reply.status !== 200 || !reply.data.digest) {
|
|
4356
|
+
fail(reply.data.error ?? `The runtime was not kept (${String(reply.status)}).`);
|
|
4357
|
+
return;
|
|
4358
|
+
}
|
|
4359
|
+
console.log(`${chalk.green("✓")} ${connection.space.name}’s draft runtime is ${reply.data.digest.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KB). It starts in a moment; publish the space to take it live.`);
|
|
1256
4360
|
};
|
|
1257
|
-
var
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
4361
|
+
var readRuntime = async (connection, spaceId) => {
|
|
4362
|
+
const answered = await authorizedRequest(connection, `/spaces/${String(spaceId)}/runtime`);
|
|
4363
|
+
if (!answered.ok) {
|
|
4364
|
+
fail(answered.error);
|
|
4365
|
+
return;
|
|
4366
|
+
}
|
|
4367
|
+
const { reply } = answered.value;
|
|
4368
|
+
if (reply.status !== 200) {
|
|
4369
|
+
fail(reply.data.error ?? `Could not read the runtime (${String(reply.status)}).`);
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
return {
|
|
4373
|
+
environments: reply.data.environments ?? [],
|
|
4374
|
+
variables: reply.data.variables ?? [],
|
|
4375
|
+
sizes: reply.data.sizes ?? []
|
|
1272
4376
|
};
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
4377
|
+
};
|
|
4378
|
+
/** How each environment's runtime is, and the names of its variables. */
|
|
4379
|
+
var runtimeStatus = async (options) => {
|
|
4380
|
+
const connection = await connect(options, "to read");
|
|
4381
|
+
if (!connection?.space) return;
|
|
4382
|
+
const runtime = await readRuntime(connection, connection.space.id);
|
|
4383
|
+
if (!runtime) return;
|
|
4384
|
+
if (!runtime.environments.length) console.log(`${connection.space.name} has no runtime. Push one: plitzi runtime push.`);
|
|
4385
|
+
const spendOf = (name) => {
|
|
4386
|
+
const size = runtime.sizes.find((option) => option.name === name);
|
|
4387
|
+
return size ? `${size.label}, ${size.cpu} CPU / ${size.memory}` : name;
|
|
1279
4388
|
};
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
4389
|
+
runtime.environments.forEach(({ environment, revision, digest, status, error, endpoints, tasks, size, ...rest }) => {
|
|
4390
|
+
const version = revision === 0 ? "draft" : `revision ${String(revision)}`;
|
|
4391
|
+
console.log(`${chalk.bold(environment)} (${version}, ${digest.slice(0, 12)}): ${status} — ${spendOf(size)}`);
|
|
4392
|
+
if (status === "stopped") {
|
|
4393
|
+
const why = rest.stoppedReason === "idle" ? "unused for too long" : "stopped by hand";
|
|
4394
|
+
console.log(` ${chalk.yellow(`Stopped (${why}): plitzi runtime start${environment === "main" ? "" : ` --environment ${environment}`}`)}`);
|
|
4395
|
+
}
|
|
4396
|
+
if (rest.idleStopsAt) console.log(` Stops by itself on ${(/* @__PURE__ */ new Date(rest.idleStopsAt * 1e3)).toLocaleString()} if nothing uses it`);
|
|
4397
|
+
if (error) console.log(` ${chalk.red(error)}`);
|
|
4398
|
+
if (status === "ready") console.log(` ${String(tasks.length)} tasks${endpoints.length ? `, answers ${endpoints.join(", ")}` : ""}`);
|
|
4399
|
+
});
|
|
4400
|
+
console.log(`Variables: ${runtime.variables.length ? runtime.variables.join(", ") : "none"}`);
|
|
4401
|
+
const included = runtime.sizes.filter((option) => option.included).map((option) => option.name);
|
|
4402
|
+
console.log(`Sizes in this plan: ${included.length ? included.join(", ") : "none"}`);
|
|
4403
|
+
};
|
|
4404
|
+
/** Starts an environment's runtime again, or stops it — kept stopped until started. */
|
|
4405
|
+
var powerRuntime = async (power, options) => {
|
|
4406
|
+
const connection = await connect(options, "to configure");
|
|
4407
|
+
if (!connection?.space) return;
|
|
4408
|
+
const environment = options.environment ?? "main";
|
|
4409
|
+
const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/${power}`, {
|
|
4410
|
+
method: "POST",
|
|
4411
|
+
headers: { "content-type": "application/json" },
|
|
4412
|
+
body: JSON.stringify({ environment })
|
|
4413
|
+
});
|
|
4414
|
+
if (!answered.ok) {
|
|
4415
|
+
fail(answered.error);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
const { reply } = answered.value;
|
|
4419
|
+
if (reply.status !== 204) {
|
|
4420
|
+
fail(reply.data.error ?? `The runtime was not ${power === "start" ? "started" : "stopped"} (${String(reply.status)}).`);
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
console.log(`${chalk.green("✓")} ${environment} ${power === "start" ? "starts in a moment" : "stops, and stays stopped until started"}.`);
|
|
4424
|
+
};
|
|
4425
|
+
/** Chooses the size an environment's runtime runs at — one the space's plan includes — and it starts again at it. */
|
|
4426
|
+
var setRuntimeSize = async (size, options) => {
|
|
4427
|
+
const connection = await connect(options, "to configure");
|
|
4428
|
+
if (!connection?.space) return;
|
|
4429
|
+
const environment = options.environment ?? "main";
|
|
4430
|
+
const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/size`, {
|
|
4431
|
+
method: "PUT",
|
|
4432
|
+
headers: { "content-type": "application/json" },
|
|
4433
|
+
body: JSON.stringify({
|
|
4434
|
+
size,
|
|
4435
|
+
environment
|
|
4436
|
+
})
|
|
4437
|
+
});
|
|
4438
|
+
if (!answered.ok) {
|
|
4439
|
+
fail(answered.error);
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
4442
|
+
const { reply } = answered.value;
|
|
4443
|
+
if (reply.status !== 204) {
|
|
4444
|
+
fail(reply.data.error ?? `The size was not changed (${String(reply.status)}).`);
|
|
4445
|
+
return;
|
|
4446
|
+
}
|
|
4447
|
+
console.log(`${chalk.green("✓")} ${environment} runs at ${size} — the runtime starts again at it.`);
|
|
4448
|
+
};
|
|
4449
|
+
/** Everything sent on standard input: a value piped in rather than typed where the shell history keeps it. */
|
|
4450
|
+
var readStdin = async () => {
|
|
4451
|
+
const chunks = [];
|
|
4452
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
4453
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
4454
|
+
};
|
|
4455
|
+
/**
|
|
4456
|
+
* Sets one of the runtime's variables — the runtime starts again with it. Without a value it is read from standard
|
|
4457
|
+
* input (`printf %s "$URL" | plitzi runtime vars set REDIS_URL`), which keeps a secret out of the shell's history.
|
|
4458
|
+
*/
|
|
4459
|
+
var setRuntimeVariable = async (name, value, options) => {
|
|
4460
|
+
const connection = await connect(options, "to configure");
|
|
4461
|
+
if (!connection?.space) return;
|
|
4462
|
+
const given = value ?? await readStdin();
|
|
4463
|
+
const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/variables/${encodeURIComponent(name)}`, {
|
|
4464
|
+
method: "PUT",
|
|
4465
|
+
headers: { "content-type": "application/json" },
|
|
4466
|
+
body: JSON.stringify({ value: given })
|
|
4467
|
+
});
|
|
4468
|
+
if (!answered.ok) {
|
|
4469
|
+
fail(answered.error);
|
|
4470
|
+
return;
|
|
4471
|
+
}
|
|
4472
|
+
const { reply } = answered.value;
|
|
4473
|
+
if (reply.status !== 204) {
|
|
4474
|
+
fail(reply.data.error ?? `${name} was not set (${String(reply.status)}).`);
|
|
4475
|
+
return;
|
|
4476
|
+
}
|
|
4477
|
+
console.log(`${chalk.green("✓")} ${name} set — the runtime starts again with it.`);
|
|
4478
|
+
};
|
|
4479
|
+
var unsetRuntimeVariable = async (name, options) => {
|
|
4480
|
+
const connection = await connect(options, "to configure");
|
|
4481
|
+
if (!connection?.space) return;
|
|
4482
|
+
const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/variables/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
4483
|
+
if (!answered.ok) {
|
|
4484
|
+
fail(answered.error);
|
|
4485
|
+
return;
|
|
4486
|
+
}
|
|
4487
|
+
console.log(`${chalk.green("✓")} ${name} removed — the runtime starts again without it.`);
|
|
4488
|
+
};
|
|
4489
|
+
//#endregion
|
|
4490
|
+
//#region src/commands/uploadPlugin.ts
|
|
4491
|
+
/** The zips `plitzi pack plugin` leaves: at a plugin package's root, or under `dist/plugins` of any other project. */
|
|
4492
|
+
var packedZips = async (root) => {
|
|
4493
|
+
const inFolder = async (dir) => {
|
|
4494
|
+
try {
|
|
4495
|
+
return (await fs.readdir(dir)).filter((file) => file.endsWith(".zip")).map((file) => path.join(dir, file));
|
|
4496
|
+
} catch {
|
|
4497
|
+
return [];
|
|
4498
|
+
}
|
|
1289
4499
|
};
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
4500
|
+
const zips = [...await inFolder(root), ...await inFolder(path.join(root, "dist/plugins"))];
|
|
4501
|
+
const modified = await Promise.all(zips.map(async (zip) => (await fs.stat(zip)).mtimeMs));
|
|
4502
|
+
return zips.map((zip, index) => ({
|
|
4503
|
+
zip,
|
|
4504
|
+
at: modified[index]
|
|
4505
|
+
})).sort((a, b) => b.at - a.at).map(({ zip }) => zip);
|
|
4506
|
+
};
|
|
4507
|
+
/** The zip named, or the one packed here — asked for at a terminal when there are several. */
|
|
4508
|
+
var chooseZip = async (given) => {
|
|
4509
|
+
if (given) return path.resolve(given);
|
|
4510
|
+
const project = await findProject(process.cwd());
|
|
4511
|
+
const zips = project ? await packedZips(project.root) : [];
|
|
4512
|
+
if (zips.length === 0) {
|
|
4513
|
+
fail("No packed plugin here. Build one with plitzi pack plugin, or name it: plitzi upload plugin <file.zip>.");
|
|
4514
|
+
return;
|
|
4515
|
+
}
|
|
4516
|
+
if (zips.length === 1) return zips[0];
|
|
4517
|
+
const choices = zips.map((zip) => path.relative(process.cwd(), zip));
|
|
4518
|
+
if (!atTerminal()) {
|
|
4519
|
+
refuseWithoutTerminal("upload", [{
|
|
4520
|
+
flag: "<zip> (the argument)",
|
|
4521
|
+
choices,
|
|
4522
|
+
question: "Which packed plugin is to be uploaded?"
|
|
4523
|
+
}], "plitzi upload plugin stopped before uploading anything: which plugin goes to the space is a choice");
|
|
1296
4524
|
return;
|
|
1297
4525
|
}
|
|
1298
4526
|
const rl = readline.createInterface({
|
|
@@ -1300,106 +4528,160 @@ var resolveDecisions = async (options) => {
|
|
|
1300
4528
|
output: process.stdout
|
|
1301
4529
|
});
|
|
1302
4530
|
try {
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
process.exitCode = 1;
|
|
1308
|
-
return;
|
|
1309
|
-
}
|
|
1310
|
-
return {
|
|
1311
|
-
packageManager: manager,
|
|
1312
|
-
mode: tier,
|
|
1313
|
-
source: origin
|
|
1314
|
-
};
|
|
4531
|
+
return await askPick(rl, "Which packed plugin? The newest is first.", zips.map((zip, index) => ({
|
|
4532
|
+
label: choices[index],
|
|
4533
|
+
value: zip
|
|
4534
|
+
})));
|
|
1315
4535
|
} finally {
|
|
1316
4536
|
rl.close();
|
|
1317
4537
|
}
|
|
1318
4538
|
};
|
|
1319
|
-
|
|
4539
|
+
/**
|
|
4540
|
+
* The manifest a zip carries at its root, read here before anything is sent: a zip that is not a plugin is refused by
|
|
4541
|
+
* the platform too, but only after it has travelled.
|
|
4542
|
+
*/
|
|
4543
|
+
var manifestOf = (zip) => {
|
|
4544
|
+
try {
|
|
4545
|
+
const entry = Object.values(unzipSync(zip, { filter: (file) => file.name === "plugin-manifest.json" })).at(0);
|
|
4546
|
+
const parsed = entry ? JSON.parse(new TextDecoder().decode(entry)) : {};
|
|
4547
|
+
return typeof parsed.root === "string" && parsed.root ? {
|
|
4548
|
+
root: parsed.root,
|
|
4549
|
+
version: typeof parsed.version === "string" ? parsed.version : ""
|
|
4550
|
+
} : void 0;
|
|
4551
|
+
} catch {
|
|
4552
|
+
return;
|
|
4553
|
+
}
|
|
4554
|
+
};
|
|
4555
|
+
var chooseCdn = async (cdns, given, spaceName) => {
|
|
4556
|
+
if (cdns.length === 0) {
|
|
4557
|
+
fail(`${spaceName} has no CDN to put a plugin on. Add one in the builder, under the space's settings.`);
|
|
4558
|
+
return;
|
|
4559
|
+
}
|
|
4560
|
+
if (given) {
|
|
4561
|
+
const named = cdns.find((cdn) => cdn.identifier === given);
|
|
4562
|
+
if (!named) fail(`${spaceName} has no CDN "${given}". Its CDNs: ${cdns.map((cdn) => cdn.identifier).join(", ")}.`);
|
|
4563
|
+
return named;
|
|
4564
|
+
}
|
|
4565
|
+
if (cdns.length === 1) return cdns[0];
|
|
4566
|
+
if (!atTerminal()) {
|
|
4567
|
+
refuseWithoutTerminal("upload", [{
|
|
4568
|
+
flag: "--cdn",
|
|
4569
|
+
choices: cdns.map((cdn) => cdn.identifier),
|
|
4570
|
+
question: `Which of ${spaceName}'s CDNs does the plugin go on?`
|
|
4571
|
+
}], "plitzi upload plugin stopped before uploading anything: which CDN serves the plugin is a choice");
|
|
4572
|
+
return;
|
|
4573
|
+
}
|
|
1320
4574
|
const rl = readline.createInterface({
|
|
1321
4575
|
input: process.stdin,
|
|
1322
4576
|
output: process.stdout
|
|
1323
4577
|
});
|
|
1324
4578
|
try {
|
|
1325
|
-
|
|
1326
|
-
|
|
4579
|
+
return await askPick(rl, `Which of ${spaceName}'s CDNs does the plugin go on?`, cdns.map((cdn) => ({
|
|
4580
|
+
label: `${cdn.name} ${chalk.dim(`${cdn.identifier} · ${cdn.domain}`)}`,
|
|
4581
|
+
value: cdn
|
|
4582
|
+
})));
|
|
1327
4583
|
} finally {
|
|
1328
4584
|
rl.close();
|
|
1329
4585
|
}
|
|
1330
4586
|
};
|
|
1331
|
-
var
|
|
1332
|
-
const
|
|
1333
|
-
const
|
|
1334
|
-
if (!
|
|
1335
|
-
|
|
1336
|
-
|
|
4587
|
+
var uploadPluginCommand = async (zipGiven, options) => {
|
|
4588
|
+
const api = await apiFor(options);
|
|
4589
|
+
const zipPath = api ? await chooseZip(zipGiven) : void 0;
|
|
4590
|
+
if (!api || !zipPath) return;
|
|
4591
|
+
let zip;
|
|
4592
|
+
try {
|
|
4593
|
+
zip = await fs.readFile(zipPath);
|
|
4594
|
+
} catch {
|
|
4595
|
+
fail(`Could not read ${zipPath}.`);
|
|
1337
4596
|
return;
|
|
1338
4597
|
}
|
|
1339
|
-
const
|
|
1340
|
-
if (!
|
|
1341
|
-
|
|
1342
|
-
const key = source === "cloud" ? options.key ?? await askForKey(mode) : "";
|
|
1343
|
-
if (source === "cloud" && !key) {
|
|
1344
|
-
console.error(chalk.red("\nNo key. Mint one under Credentials in the builder, then run this again."));
|
|
1345
|
-
process.exitCode = 1;
|
|
4598
|
+
const manifest = manifestOf(zip);
|
|
4599
|
+
if (!manifest) {
|
|
4600
|
+
fail(`${path.basename(zipPath)} is not a plugin: it has no plugin-manifest.json at its root. Build it with plitzi pack plugin.`);
|
|
1346
4601
|
return;
|
|
1347
4602
|
}
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
4603
|
+
const connection = await connectionWithSpace(api, "to upload to");
|
|
4604
|
+
if (!connection?.space) return;
|
|
4605
|
+
const { space } = connection;
|
|
4606
|
+
const listed = await authorizedRequest(connection, `/spaces/${space.id}/cdns`);
|
|
4607
|
+
if (!listed.ok) {
|
|
4608
|
+
fail(listed.error);
|
|
4609
|
+
return;
|
|
4610
|
+
}
|
|
4611
|
+
if (listed.value.reply.status !== 200) {
|
|
4612
|
+
fail(listed.value.reply.data.error ?? `Could not list ${space.name}'s CDNs (${listed.value.reply.status}).`);
|
|
4613
|
+
return;
|
|
4614
|
+
}
|
|
4615
|
+
const cdn = await chooseCdn(listed.value.reply.data.cdns ?? [], options.cdn, space.name);
|
|
4616
|
+
if (!cdn) return;
|
|
4617
|
+
const label = `${manifest.root}${manifest.version ? ` ${manifest.version}` : ""}`;
|
|
4618
|
+
console.log(`\nUploading ${chalk.bold(label)} to ${chalk.bold(space.name)}, on ${cdn.name}…`);
|
|
4619
|
+
const query = new URLSearchParams({ filename: path.basename(zipPath) });
|
|
4620
|
+
const uploaded = await authorizedRequest(listed.value.connection, `/spaces/${space.id}/cdns/${encodeURIComponent(cdn.identifier)}/plugins?${query}`, {
|
|
4621
|
+
method: "POST",
|
|
4622
|
+
headers: { "Content-Type": "application/zip" },
|
|
4623
|
+
body: new Uint8Array(zip)
|
|
1356
4624
|
});
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
return fs.writeFile(destination, contents);
|
|
1361
|
-
}));
|
|
1362
|
-
const wantsInstall = options.install !== false;
|
|
1363
|
-
const installed = wantsInstall && await install(packageManager, target);
|
|
1364
|
-
/**
|
|
1365
|
-
* A failed install is a failed command, not a next step.
|
|
1366
|
-
*
|
|
1367
|
-
* The files are already written, so the summary below still prints — but printed on its own after a screen of
|
|
1368
|
-
* resolver errors it reads as success, and a script that ran `create` carries on into a project with no
|
|
1369
|
-
* `node_modules`.
|
|
1370
|
-
*/
|
|
1371
|
-
if (wantsInstall && !installed) {
|
|
1372
|
-
console.error(chalk.red(`\n\`${installCommand(packageManager)}\` failed. The project is written; the reason is in the output above.`));
|
|
1373
|
-
console.error(chalk.dim(INSTALL_HINTS[packageManager]));
|
|
1374
|
-
process.exitCode = 1;
|
|
4625
|
+
if (!uploaded.ok) {
|
|
4626
|
+
fail(uploaded.error);
|
|
4627
|
+
return;
|
|
1375
4628
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
console.log(` ${
|
|
1388
|
-
console.log("");
|
|
1389
|
-
console.log(chalk.dim(`.claude/skills carries Plitzi's authoring skill; \`${runCommand(packageManager, "visual")}\` opens the page and checks it.`));
|
|
1390
|
-
if (source === "cloud") console.log(chalk.dim(".env holds the key and is already git-ignored."));
|
|
1391
|
-
console.log("");
|
|
4629
|
+
const { status, data } = uploaded.value.reply;
|
|
4630
|
+
if (status === 413) {
|
|
4631
|
+
fail(`${path.basename(zipPath)} is larger than ${api} accepts.`);
|
|
4632
|
+
return;
|
|
4633
|
+
}
|
|
4634
|
+
if (status !== 201) {
|
|
4635
|
+
fail(data.error ?? `The upload was refused (${status}).`);
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4638
|
+
const installed = data.installed === "updated" ? `now loads ${label}` : `loads ${label} from now on`;
|
|
4639
|
+
console.log(chalk.green(`\n${space.name} ${installed}.`));
|
|
4640
|
+
if (data.resource?.path) console.log(chalk.dim(` ${data.resource.path}`));
|
|
4641
|
+
console.log(chalk.dim(" Any builder open on the space loads it now.\n"));
|
|
1392
4642
|
};
|
|
1393
4643
|
//#endregion
|
|
1394
4644
|
//#region src/index.ts
|
|
1395
4645
|
/**
|
|
1396
4646
|
* The command line for Plitzi.
|
|
1397
4647
|
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
4648
|
+
* The gap it closes first is between installing the SDK and having a page on screen. Everything needed for that was
|
|
4649
|
+
* documented, which was the problem — a person had to read four things and write three files correctly before they
|
|
4650
|
+
* could tell whether any of it worked. The same holds for an element of one's own, which is the second thing it does.
|
|
1401
4651
|
*/
|
|
1402
4652
|
program.name("plitzi").description("Plitzi command line");
|
|
1403
|
-
|
|
4653
|
+
/** The flags that shape a PROJECT: given with `--plugin`, they are a mistake worth saying rather than ignoring. */
|
|
4654
|
+
var PROJECT_ONLY = [
|
|
4655
|
+
"mode",
|
|
4656
|
+
"source",
|
|
4657
|
+
"key"
|
|
4658
|
+
];
|
|
4659
|
+
program.command("create").argument("[directory]", "Where to write it. Defaults to the current directory, or asked for with --plugin.").description("Scaffold a project that renders a Plitzi space, or with --plugin a plugin package").addOption(new Option("-m, --mode <mode>", "server (SSR + RSC on a Node tier) or client (browser only)").choices(["server", "client"])).addOption(new Option("-s, --source <source>", "local (the space travels in the project) or cloud (read it from Plitzi)").choices(["local", "cloud"])).option("-k, --key <key>", "Cloud only: the space key (asked for when omitted)").option("-e, --environment <environment>", "Which version to serve: main, or a published environment", "main").addOption(new Option("-p, --package-manager <manager>", "The package manager the project is written for. Asked for when left out.").choices([...PACKAGE_MANAGERS])).option("--plugin", "A plugin package instead: one element any space can load, with a preview and a build").option("--name <name>", "Plugin only: its package name (plitzi-plugin-seat-picker). Asked for when left out.").option("--title <title>", "Plugin only: what the builder calls the element").option("--description <description>", "Plugin only: what the element is for, in a sentence").option("--owner <owner>", "Plugin only: who publishes it").option("--elements <names>", "Plugin only: other elements the package holds, by name, separated by commas").option("--no-install", "Write the files without installing dependencies").option("-f, --force", "Write into a directory that is not empty").option("-y, --yes", "At a terminal: take the defaults for any choice not passed. Without one, every choice must be passed").action((directory, options) => {
|
|
4660
|
+
if (!options.plugin) return create(directory, options);
|
|
4661
|
+
const stray = PROJECT_ONLY.filter((flag) => options[flag] !== void 0);
|
|
4662
|
+
if (stray.length > 0) program.error(`--${stray.join(", --")} shape a project, not a plugin: leave them out with --plugin.`);
|
|
4663
|
+
return createPlugin(directory, options);
|
|
4664
|
+
});
|
|
4665
|
+
program.command("add").description("Add something to the project you are in").command("plugin").argument("[names...]", "What each element is called: seat-picker. Asked for when left out.").description("Add elements of your own to this project: each one its component, declaration and builder panel").option("-d, --dir <folder>", "The folder that holds the project’s components. Asked for, unless the project says.").option("--title <title>", "One element only: what the builder calls it").option("--description <description>", "One element only: what it is for, in a sentence").option("-f, --force", "Write into a folder that is not empty").action((names, options) => addPlugin(names, options));
|
|
4666
|
+
program.command("pack").description("Build something of this project into what the platform takes").command("plugin").argument("[folders...]", "Element folders to pack, the main one first. In a plugin package, left out: all of them.").description("Build a plugin: one ES module, its plugin-manifest.json, and the zip the builder takes under Resources").option("-o, --out <folder>", "Where the build goes. Emptied first; it must be inside the project.").option("--no-zip", "Build without the zip").option("--plugin-version <version>", "The version the manifest carries. Defaults to the one in package.json.").action((folders, options) => packPluginCommand(folders, options));
|
|
4667
|
+
var API_OPTION = ["--api <url>", "The platform’s API. Defaults to PLITZI_API_URL, else the one signed in to, else https://api.plitzi.com."];
|
|
4668
|
+
program.command("login").description("Sign in, in your browser. The session is kept and renewed until plitzi logout").option(...API_OPTION).action((options) => login(options));
|
|
4669
|
+
program.command("logout").description("Sign out: the session is revoked on the platform and forgotten here").action(() => logout());
|
|
4670
|
+
program.command("whoami").description("Who the CLI is signed in as, and the space it works in").option(...API_OPTION).action((options) => whoami(options));
|
|
4671
|
+
program.command("space").description("Choose the space to work in, in your browser. One at a time: it replaces the one chosen before").option(...API_OPTION).action((options) => space(options));
|
|
4672
|
+
program.command("upload").description("Put something of this project on the space you work in").command("plugin").argument("[zip]", "The zip plitzi pack plugin built. Left out: the one packed in this project.").description("Upload a packed plugin to a CDN of the space you work in, and install it there").option("--cdn <identifier>", "Which of the space’s CDNs. Asked for when it has several.").option(...API_OPTION).action((zip, options) => uploadPluginCommand(zip, options));
|
|
4673
|
+
var functions = program.command("functions").description("The space’s own server code: functions/ in this project is a working copy of it");
|
|
4674
|
+
functions.command("pull").description("Write the space’s functions into functions/ — refused when that would overwrite what is not pushed").option("-f, --force", "Overwrite what is not pushed, or a copy of another space").option(...API_OPTION).action((options) => pullFunctions(options));
|
|
4675
|
+
functions.command("push").description("Save functions/ as the space’s draft: built and checked on the platform, refused if it moved on since").option(...API_OPTION).action((options) => pushFunctions(options));
|
|
4676
|
+
functions.command("try").argument("<task>", "The task, <namespace>.<action>").description("Run one task of the draft in the sandbox: its value, what it logged, and why it failed").option("--params <json>", "Its params, as a JSON object").option(...API_OPTION).action((task, options) => tryFunction(task, options));
|
|
4677
|
+
functions.command("dev").argument("<task>", "The task, <namespace>.<action>").description("Run one task from functions/ on this machine, as the platform runs it — nothing reaches the space").option("--params <json>", "Its params, as a JSON object").option("-w, --watch", "Run it again every time a file of functions/ is saved").action((task, options) => devFunction(task, options));
|
|
4678
|
+
var runtime = program.command("runtime").description("The space’s runtime: its own server code, run as a process of its own beside the platform");
|
|
4679
|
+
runtime.command("push").description("Pack this project’s runtime module and keep it as the space’s draft runtime").option("--entry <path>", "The module whose default export is defineRuntime(…)", "src/runtime.ts").option(...API_OPTION).action((options) => pushRuntime(options));
|
|
4680
|
+
runtime.command("status").description("How each environment’s runtime is, and the names of its variables").option(...API_OPTION).action((options) => runtimeStatus(options));
|
|
4681
|
+
for (const power of ["start", "stop"]) runtime.command(power).description(power === "start" ? "Start an environment’s runtime again — one stopped for being unused, or by hand" : "Stop an environment’s runtime, and keep it stopped until it is started").option("--environment <name>", "The environment: main (the draft) or a published one", "main").option(...API_OPTION).action((options) => powerRuntime(power, options));
|
|
4682
|
+
runtime.command("size").description("Choose the size an environment’s runtime runs at, among those the space’s plan includes").argument("<size>", "small, medium or large — plitzi runtime status says which the plan includes").option("--environment <name>", "The environment: main (the draft) or a published one", "main").option(...API_OPTION).action((size, options) => setRuntimeSize(size, options));
|
|
4683
|
+
var vars = runtime.command("vars").description("What the runtime starts with — written, never read back");
|
|
4684
|
+
vars.command("set").argument("<name>", "The variable, in capitals: REDIS_URL").argument("[value]", "Its value — read from standard input when left out, which keeps it out of the shell history").option(...API_OPTION).action((name, value, options) => setRuntimeVariable(name, value, options));
|
|
4685
|
+
vars.command("unset").argument("<name>", "The variable").option(...API_OPTION).action((name, options) => unsetRuntimeVariable(name, options));
|
|
1404
4686
|
program.parse(process.argv);
|
|
1405
4687
|
//#endregion
|