@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/dist/index.js CHANGED
@@ -1,13 +1,648 @@
1
1
  #!/usr/bin/env node
2
2
  import { Option, program } from "commander";
3
- import { spawn, spawnSync } from "node:child_process";
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, by the \`renderType\` the space names them with.
94
- *
95
- * A \`custom\` element naming \`statCard\` renders this component, and the element's attributes arrive as its
96
- * props — which is what makes a plugin bindable rather than static. There is no server here, so it is part of
97
- * this project's bundle and Vite hot-replaces it like any other module. Add another by writing it under
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 plugins = { statCard: { component: StatCard } };`;
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 StatCard from './plugins/StatCard';
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$1 = ({ mode }) => `# Plugins
1011
+ var readme$2 = ({ mode }) => `# Plugins
367
1012
 
368
1013
  Components of your own, rendered by the space.
369
1014
 
370
- A plugin is a React component and a \`renderType\` it is registered under. The space hosts it with a \`custom\`
371
- element naming that same \`renderType\` — see \`custom({ renderType: 'statCard', … })\` in \`src/space.ts\` — and the
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 this one with \`action: 'compile'\`, which is what makes it **server-rendered**: the
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\` registers this one as the third argument to \`render()\`. There is no server here, so the
379
- component is part of this project's own bundle and Vite hot-replaces it like any other module.`}
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
- 1. \`src/plugins/YourThing/YourThing.tsx\` — a component whose props are the attributes you want to author.
384
- 2. \`src/plugins/YourThing/index.ts\` — \`export default\`, so the registration has one thing to point at.
385
- 3. Register it in \`src/main.ts\` under a \`renderType\`.
386
- 4. Put a \`custom({ renderType: 'yourThing', … })\` in \`src/space.ts\`.
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$1(answers)
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 a server-mode project cannot start that way: `node --import tsx`
439
- * dies on its first import with "Some options passed to require() aren't supported by PnP yet (conditions)" —
440
- * tsx's resolver calls into Node's with conditions PnP does not implement. `node-modules` is the layout npm and
441
- * pnpm already give it, so pinning it is what makes all three managers produce a project that runs, rather than
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: "^19.2.8",
543
- "react-dom": "^19.2.8"
1212
+ react: REACT_VERSION,
1213
+ "react-dom": REACT_VERSION
544
1214
  });
545
- var devDependencies = ({ mode }) => mode === "server" ? {
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
- tsx: "^4.23.12"
548
- } : {
549
- ...SHARED_DEV_DEPENDENCIES,
550
- vite: "^8.2.1"
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 `tsx watch`: there is no client bundle of this
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 --import tsx src/main.ts",
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 --import tsx --watch-path=./src src/main.ts"
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 --import tsx src/author.ts" } : {},
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
- var tsconfig = ({ mode }) => `${JSON.stringify({
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. Claude Code reads it automatically; \`AGENTS.md\` points any other agent at it.
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
- ".gitignore": gitignore(answers),
746
- "README.md": readme(answers),
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
- var prettierignore = () => "node_modules\ndist\nspace\nvisual/.results\nvisual/screenshots\n";
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 = ({ mode }) => `import js from '@eslint/js';
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: ['dist', 'space', '.sdk-plugins', 'visual/.results', 'visual/screenshots'] },
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.${mode === "server" ? "node" : "browser"} },
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
- var qualityFiles = (answers) => ({
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(answers)
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, by the \`renderType\` the space names them with.
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. Add another by
842
- * writing it under \`src/plugins\` and adding a line here; see \`src/plugins/README.md\`.
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
- const plugins = {
845
- statCard: {
846
- js: path.resolve(import.meta.dirname, 'plugins/StatCard/index.ts'),
847
- action: 'compile' as const,
848
- version: '1.0.0'
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 path from 'node:path';
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 { space } from './space';
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
- const { schema, style, warnings } = authorSpace(space);
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, '127.0.0.1');
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 path from 'node:path';
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, '127.0.0.1');
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
- * Only `plitzi-authoring` travels. `plitzi-render` is the other one Plitzi ships, and it is deliberately left
984
- * out: it drives the `plitzi_render` MCP tool, so in a project with no MCP connection it would be an instruction
985
- * to use something that is not there.
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
- package: "@plitzi/sdk-authoring",
990
- name: "plitzi-authoring"
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
- var skillFiles = () => {
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 skill of SKILLS) try {
1015
- const root = dirname(skillPath(skill.package, skill.name));
1016
- for (const [path, content] of filesUnder(root)) files[`.claude/skills/${skill.name}/${path}`] = content;
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 { space } from './space';
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 the numbers it is authored with.
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. The settings are what the component reads as props — a plain object here, a JSON string on the element,
1065
- * and the same names on both sides.
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 PLUGIN_HOST = {
1833
+ var pluginHost = ({ mode }) => mode === "client" ? {
1068
1834
  id: "stat-card",
1069
1835
  renderType: "statCard",
1070
- settings: {
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
- series: [
1075
- 8,
1076
- 12,
1077
- 9,
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: PLUGIN_HOST
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, locate } from '@plitzi/sdk-authoring';
1894
+ import { authorSpace, inspectPage } from '@plitzi/sdk-authoring';
1123
1895
 
1124
- import { space } from '../src/space';
1896
+ import { declarations } from '../src/plugins/declarations.ts';
1897
+ import { space } from '../src/space.ts';
1125
1898
 
1126
1899
  /**
1127
- * Everything this space NAMES is on screen, on every page it has.
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}}\`), and an element that is
1135
- * on screen only under a condition — a menu that opens on a tap, a confirmation after a submit.
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 every element it names\`, async ({ page }) => {
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 visualFiles = (answers) => ({
1182
- "playwright.config.ts": playwrightConfig(answers),
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/index.ts
2002
+ //#region src/scaffold/pluginPackage/build.ts
1187
2003
  /**
1188
- * Every file of a generated project, assembled from the two decisions that shape it.
2004
+ * The preview's Vite config — and only the preview's.
1189
2005
  *
1190
- * Pure, and separate from the command that writes them, so what the scaffold SAYS can be asserted without a
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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/commands/create.ts
1206
- var MODES = ["server", "client"];
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 (await fs.readdir(target)).length === 0;
3976
+ return createRequire(path.join(root, "package.json"))("typescript");
1212
3977
  } catch {
1213
- return true;
3978
+ return;
1214
3979
  }
1215
3980
  };
1216
- var isDirectory = async (candidate) => {
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 (await fs.stat(candidate)).isDirectory();
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 closest directory that already exists: the project's own does not until its files are written. */
1224
- var nearestExisting = async (target) => {
1225
- let dir = target;
1226
- while (!await isDirectory(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
1227
- return dir;
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
- * What a first install is most likely refused over, and what to do about it.
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 INSTALL_HINTS = {
1237
- npm: "A peer-dependency conflict (ERESOLVE) is a bug in this scaffold: please report it. `npm install --legacy-peer-deps` gets past it meanwhile.",
1238
- yarn: "YN0016 (\"quarantined\") means a dependency was published less than a day ago. Wait, or add it to `npmPreapprovedPackages` in .yarnrc.yml.",
1239
- pnpm: "A dependency published too recently is added to `minimumReleaseAgeExclude` in pnpm-workspace.yaml; a skipped build script is approved with `pnpm approve-builds`."
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 install = (manager, cwd) => new Promise((resolve) => {
1242
- const child = spawn(manager, ["install"], {
1243
- cwd,
1244
- stdio: "inherit",
1245
- shell: process.platform === "win32"
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
- child.on("error", () => resolve(false));
1248
- child.on("close", (code) => resolve(code === 0));
1249
- });
1250
- /** One choice at the terminal: Enter takes the default, anything off the list is refused rather than guessed at. */
1251
- var ask = async (rl, question) => {
1252
- const reply = (await rl.question(`\n${question.help}\n${question.flag} (${question.choices.join("/")}) [${question.fallback}] > `)).trim();
1253
- const chosen = question.choices.find((choice) => choice === (reply || question.fallback));
1254
- if (!chosen) console.error(chalk.red(`"${reply}" is not one of ${question.choices.join(", ")}.`));
1255
- return chosen;
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 resolveDecisions = async (options) => {
1258
- const detected = detectPackageManager();
1259
- const packageManager = {
1260
- flag: "--package-manager",
1261
- choices: PACKAGE_MANAGERS,
1262
- help: `The package manager the project will be worked in (this was run through ${detected}).`,
1263
- given: PACKAGE_MANAGERS.find((manager) => manager === options.packageManager),
1264
- fallback: detected
1265
- };
1266
- const mode = {
1267
- flag: "--mode",
1268
- choices: MODES,
1269
- help: "server: SSR + RSC on a Node tier. client: browser only.",
1270
- given: MODES.find((candidate) => candidate === options.mode),
1271
- fallback: "server"
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
- const source = {
1274
- flag: "--source",
1275
- choices: SOURCES,
1276
- help: "local: the space travels in the project. cloud: read it from Plitzi.",
1277
- given: SOURCES.find((candidate) => candidate === options.source),
1278
- fallback: "local"
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
- const missing = [
1281
- packageManager,
1282
- mode,
1283
- source
1284
- ].filter((question) => question.given === void 0);
1285
- if (missing.length === 0 || options.yes) return {
1286
- packageManager: packageManager.given ?? packageManager.fallback,
1287
- mode: mode.given ?? mode.fallback,
1288
- source: source.given ?? source.fallback
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
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
1291
- console.error(chalk.red("\nplitzi create needs these decided by whoever the project is for. Ask them; do not assume:"));
1292
- for (const question of missing) console.error(` ${`${question.flag} ${question.choices.join("|")}`.padEnd(36)} ${chalk.dim(question.help)}`);
1293
- const defaults = missing.map((question) => `${question.flag} ${question.fallback}`).join(" ");
1294
- console.error(chalk.dim(`\nThen run it again with those flags — or with --yes to take ${defaults}.`));
1295
- process.exitCode = 1;
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
- const manager = packageManager.given ?? await ask(rl, packageManager);
1304
- const tier = manager && (mode.given ?? await ask(rl, mode));
1305
- const origin = tier && (source.given ?? await ask(rl, source));
1306
- if (!manager || !tier || !origin) {
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
- var askForKey = async (mode) => {
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
- const which = mode === "server" ? "self-hosting" : "public render";
1326
- return (await rl.question(`\n${which} key (Credentials, in the builder)\n> `)).trim();
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 create = async (directory, options) => {
1332
- const target = path.resolve(directory ?? ".");
1333
- const name = path.basename(target);
1334
- if (!options.force && !await isEmpty(target)) {
1335
- console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
1336
- process.exitCode = 1;
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 decisions = await resolveDecisions(options);
1340
- if (!decisions) return;
1341
- const { packageManager, mode, source } = decisions;
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 files = scaffold({
1349
- name,
1350
- mode,
1351
- source,
1352
- key,
1353
- environment: options.environment ?? "main",
1354
- packageManager,
1355
- managerVersion: detectManagerVersion(packageManager, await nearestExisting(target))
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
- await Promise.all(Object.entries(files).map(async ([file, contents]) => {
1358
- const destination = path.join(target, file);
1359
- await fs.mkdir(path.dirname(destination), { recursive: true });
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
- * How to get there, in whichever form is shorter.
1378
- *
1379
- * A relative path out of a temp directory is six `../` and unreadable; an absolute one to a sibling folder is
1380
- * noise. These lines are meant to be pasted, so they are printed the way somebody would have typed them.
1381
- */
1382
- const relative = path.relative(process.cwd(), target);
1383
- const where = target === process.cwd() ? "" : `cd ${relative.length < target.length ? relative : target} && `;
1384
- console.log(chalk.green(`\n${name} — ${mode === "server" ? "server-rendered" : "browser-rendered"}, space ${source === "cloud" ? "from Plitzi" : "in the project"}.`));
1385
- console.log("");
1386
- if (!installed) console.log(` ${where}${installCommand(packageManager)}`);
1387
- console.log(` ${where}${runCommand(packageManager, "start")}`);
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
- * One command, and deliberately: the gap worth closing first is between installing the SDK and having a page on
1399
- * screen. Everything needed for that was documented, which was the problem — a person had to read four things and
1400
- * write three files correctly before they could tell whether any of it worked.
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
- program.command("create").argument("[directory]", "Where to write the project. Defaults to the current directory.").description("Scaffold a project that renders a Plitzi space").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("--no-install", "Write the files without installing dependencies").option("-f, --force", "Write into a directory that is not empty").option("-y, --yes", "Take the defaults for any choice not passed (server, local, the invoking package manager)").action(create);
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