@greatstore/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +95 -0
  2. package/dist/cli.js +950 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @greatstore/cli
2
+
3
+ Author and ship GreatStore custom remote components from the command line.
4
+
5
+ ```
6
+ npm install -g @greatstore/cli
7
+ gs login
8
+ gs init my-widget
9
+ cd my-widget && npm install && npm run build
10
+ gs push
11
+ gs publish my-widget
12
+ ```
13
+
14
+ > **Status — MVP.** Authentication today targets a nav-side OAuth bounce
15
+ > at `nav.maker.co/connect_oauth_done` and a short-lived JWT stored at
16
+ > `~/.greatstore/credentials.json`. Bearer acceptance on the worker is a
17
+ > separate planned change (see
18
+ > `tasks/backburner/builder-cli-bearer-auth.md` in the main repo); until
19
+ > that lands, push/publish/list etc. will 401 against the live API.
20
+
21
+ ## Commands
22
+
23
+ | Command | What it does |
24
+ | --------------------------------------------- | ------------------------------------------------------------- |
25
+ | `gs login [--store <slug>]` | Open the browser, capture a nav token, persist credentials. |
26
+ | `gs logout` | Wipe `~/.greatstore/credentials.json`. |
27
+ | `gs whoami` | Print the identity stored in your credentials file. |
28
+ | `gs init <name> [--out <dir>] [--store <s>]` | Scaffold a working component project. |
29
+ | `gs list [--json]` | List the components in the current store. |
30
+ | `gs pull <name> [--draft\|--live\|--version N] [-o <dir>]` | Download manifest + bundle to disk. |
31
+ | `gs push [<name>] [--manifest path] [--bundle path]` | Upload a new draft revision. |
32
+ | `gs publish <name> [--version N]` | Promote a draft (or rollback to a historical version) to live.|
33
+ | `gs unpublish <name>` | Clear the live pointer; the draft and history stay. |
34
+ | `gs delete <name> [--yes]` | Soft-delete the component (R2 objects are retained). |
35
+
36
+ ## Store selection
37
+
38
+ Every command except `login`, `logout`, and `whoami` needs a store. The
39
+ CLI resolves it with this precedence:
40
+
41
+ 1. `--store <slug>` flag.
42
+ 2. `.gsrc` in the current directory or any ancestor: `{"store":"demo"}`.
43
+ 3. `GS_STORE` environment variable.
44
+ 4. Error.
45
+
46
+ `gs init` writes a `.gsrc` for you so commands inside a scaffolded
47
+ project don't need a flag.
48
+
49
+ ## Environment
50
+
51
+ - `GS_API_BASE` — override the API base URL (default
52
+ `https://<slug>.greatstore.ai`). Used for local dev:
53
+ `GS_API_BASE=http://demo.localhost:8787`.
54
+ - `GS_NAV_BASE` — override the nav OAuth base (default
55
+ `https://nav.maker.co`). Used for testing against a non-prod Clerk
56
+ tenant.
57
+ - `GS_BROWSER_CMD` — override the command used to open the browser
58
+ during `gs login`. Defaults to `open` on macOS, `xdg-open` on Linux,
59
+ `start` on Windows.
60
+
61
+ ## Credentials storage
62
+
63
+ `~/.greatstore/credentials.json` is created with mode `0700` on its
64
+ directory and `0600` on the file. The CLI verifies this on every read
65
+ and refuses to use a file with broader permissions.
66
+
67
+ The credentials file holds a single short-lived JWT plus its decoded
68
+ identity claims. There is no refresh token; when the JWT expires, the
69
+ next command prompts for re-auth and re-runs the browser flow.
70
+
71
+ ## Development
72
+
73
+ ```
74
+ cd cli
75
+ npm install
76
+ npm run typecheck
77
+ npm test
78
+ npm run build
79
+ node dist/cli.js --help
80
+ ```
81
+
82
+ To exercise against local `wrangler dev`, point at the tenant host:
83
+
84
+ ```
85
+ GS_API_BASE=http://demo.localhost:8787 \
86
+ GS_NAV_BASE=https://nav.maker.co \
87
+ node dist/cli.js list --store demo
88
+ ```
89
+
90
+ ## Release flow
91
+
92
+ 1. Bump `cli/package.json` version.
93
+ 2. Tag: `git tag cli-v0.0.2 && git push origin cli-v0.0.2`.
94
+ 3. `cli-publish` GitHub Actions workflow runs typecheck + test + build,
95
+ then `npm publish --access public` with `NPM_TOKEN`.
package/dist/cli.js ADDED
@@ -0,0 +1,950 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ var SHORT_ALIASES = {
5
+ h: "help",
6
+ v: "version",
7
+ o: "out"
8
+ };
9
+ function parseArgs(argv) {
10
+ const out = { command: null, positional: [], flags: {} };
11
+ if (argv.length === 0) return out;
12
+ let i = 0;
13
+ const first = argv[0];
14
+ if (first && !first.startsWith("-")) {
15
+ out.command = first;
16
+ i = 1;
17
+ }
18
+ for (; i < argv.length; i++) {
19
+ const token = argv[i] ?? "";
20
+ if (token.startsWith("--")) {
21
+ const eq = token.indexOf("=");
22
+ let key;
23
+ let value;
24
+ if (eq !== -1) {
25
+ key = token.slice(2, eq);
26
+ value = token.slice(eq + 1);
27
+ } else {
28
+ key = token.slice(2);
29
+ const next = argv[i + 1];
30
+ if (next === void 0 || next.startsWith("--") || next.startsWith("-")) {
31
+ value = true;
32
+ } else {
33
+ value = next;
34
+ i++;
35
+ }
36
+ }
37
+ if (key) out.flags[key] = value;
38
+ } else if (token.startsWith("-") && token.length > 1) {
39
+ const short = token.slice(1);
40
+ const aliased = SHORT_ALIASES[short];
41
+ if (!aliased) continue;
42
+ const next = argv[i + 1];
43
+ if (next === void 0 || next.startsWith("-")) {
44
+ out.flags[aliased] = true;
45
+ } else {
46
+ out.flags[aliased] = next;
47
+ i++;
48
+ }
49
+ } else {
50
+ out.positional.push(token);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function flagString(flags, key) {
56
+ const v = flags[key];
57
+ return typeof v === "string" ? v : void 0;
58
+ }
59
+ function flagBool(flags, key) {
60
+ const v = flags[key];
61
+ return v === true || v === "true";
62
+ }
63
+
64
+ // src/loopback.ts
65
+ import * as crypto from "crypto";
66
+ import * as http from "http";
67
+ import { spawn } from "child_process";
68
+ var CALLBACK_PATH = "/callback";
69
+ var SUCCESS_HTML = `<!doctype html>
70
+ <html lang="en"><head><meta charset="utf-8"><title>GreatStore CLI</title>
71
+ <style>body{font:14px/1.5 system-ui;margin:4rem auto;max-width:32rem;color:#222;text-align:center}
72
+ h1{font-size:1.4rem}code{background:#f4f4f4;padding:.1em .3em;border-radius:.2em}</style></head>
73
+ <body><h1>You're signed in.</h1>
74
+ <p>You can close this window and return to your terminal.</p></body></html>`;
75
+ var FAILURE_HTML = `<!doctype html>
76
+ <html lang="en"><head><meta charset="utf-8"><title>GreatStore CLI</title>
77
+ <style>body{font:14px/1.5 system-ui;margin:4rem auto;max-width:32rem;color:#222;text-align:center}
78
+ h1{font-size:1.4rem;color:#a40000}code{background:#f4f4f4;padding:.1em .3em;border-radius:.2em}</style></head>
79
+ <body><h1>Sign-in failed.</h1>
80
+ <p>Return to your terminal for details.</p></body></html>`;
81
+ var LoopbackError = class extends Error {
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = "LoopbackError";
85
+ }
86
+ };
87
+ async function captureLoopbackToken(options) {
88
+ const state = (options.generateState ?? defaultState)();
89
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
90
+ const open = options.openBrowser ?? ((url) => openInBrowser(url, options.browserCmdEnv));
91
+ const server = http.createServer();
92
+ try {
93
+ await new Promise((resolve5) => server.listen(0, "127.0.0.1", resolve5));
94
+ const address = server.address();
95
+ const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
96
+ const authUrl = `${options.navBaseUrl}/connect_oauth_done?redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(state)}`;
97
+ const tokenPromise = waitForCallback(server, state, timeoutMs);
98
+ open(authUrl);
99
+ return await tokenPromise;
100
+ } finally {
101
+ server.close();
102
+ }
103
+ }
104
+ function waitForCallback(server, expectedState, timeoutMs) {
105
+ return new Promise((resolve5, reject) => {
106
+ let settled = false;
107
+ const settle = (fn) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ fn();
111
+ };
112
+ const timer = setTimeout(() => {
113
+ settle(() => reject(new LoopbackError("Timed out waiting for sign-in. Re-run `gs login`.")));
114
+ }, timeoutMs);
115
+ if (typeof timer.unref === "function") timer.unref();
116
+ server.on("request", (req, res) => {
117
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
118
+ if (url.pathname !== CALLBACK_PATH) {
119
+ res.writeHead(404, { "content-type": "text/plain" });
120
+ res.end("Not found");
121
+ return;
122
+ }
123
+ const params = url.searchParams;
124
+ const error = params.get("error");
125
+ const token = params.get("token");
126
+ const returnedState = params.get("state");
127
+ if (returnedState !== expectedState) {
128
+ res.writeHead(400, { "content-type": "text/html" });
129
+ res.end(FAILURE_HTML);
130
+ clearTimeout(timer);
131
+ settle(() => reject(new LoopbackError("State mismatch on OAuth callback \u2014 possible CSRF, login aborted.")));
132
+ return;
133
+ }
134
+ if (error) {
135
+ res.writeHead(400, { "content-type": "text/html" });
136
+ res.end(FAILURE_HTML);
137
+ clearTimeout(timer);
138
+ settle(() => reject(new LoopbackError(`Sign-in failed: ${error}`)));
139
+ return;
140
+ }
141
+ if (!token) {
142
+ res.writeHead(400, { "content-type": "text/html" });
143
+ res.end(FAILURE_HTML);
144
+ clearTimeout(timer);
145
+ settle(() => reject(new LoopbackError("Callback missing `token` query parameter.")));
146
+ return;
147
+ }
148
+ res.writeHead(200, { "content-type": "text/html" });
149
+ res.end(SUCCESS_HTML);
150
+ clearTimeout(timer);
151
+ settle(() => resolve5({ token }));
152
+ });
153
+ });
154
+ }
155
+ function defaultState() {
156
+ return crypto.randomBytes(16).toString("hex");
157
+ }
158
+ function openInBrowser(url, overrideCmd) {
159
+ const override = overrideCmd?.trim();
160
+ const cmd = override ?? defaultBrowserCommand();
161
+ if (!cmd) {
162
+ return;
163
+ }
164
+ try {
165
+ const args = cmd === "start" ? ["", url] : [url];
166
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: cmd === "start" });
167
+ child.on("error", () => {
168
+ });
169
+ child.unref();
170
+ } catch {
171
+ }
172
+ }
173
+ function defaultBrowserCommand() {
174
+ switch (process.platform) {
175
+ case "darwin":
176
+ return "open";
177
+ case "win32":
178
+ return "start";
179
+ case "linux":
180
+ return "xdg-open";
181
+ default:
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // src/credentials.ts
187
+ import * as fs from "fs";
188
+ import * as os from "os";
189
+ import * as path from "path";
190
+ var DIR_NAME = ".greatstore";
191
+ var FILE_NAME = "credentials.json";
192
+ function credentialsDir(home = os.homedir()) {
193
+ return path.join(home, DIR_NAME);
194
+ }
195
+ function credentialsPath(home = os.homedir()) {
196
+ return path.join(credentialsDir(home), FILE_NAME);
197
+ }
198
+ function read(home = os.homedir()) {
199
+ const file = credentialsPath(home);
200
+ let raw;
201
+ try {
202
+ const stat = fs.statSync(file);
203
+ if (process.platform !== "win32") {
204
+ const mode = stat.mode & 511;
205
+ if (mode & 63) {
206
+ throw new Error(
207
+ `Refusing to read ${file}: permissions are ${mode.toString(8)} (should be 600). Run \`chmod 600 ${file}\`.`
208
+ );
209
+ }
210
+ }
211
+ raw = fs.readFileSync(file, "utf8");
212
+ } catch (err) {
213
+ if (isENOENT(err)) return null;
214
+ throw err;
215
+ }
216
+ try {
217
+ const parsed = JSON.parse(raw);
218
+ if (typeof parsed.access_token !== "string") return null;
219
+ return parsed;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+ function write(creds, home = os.homedir()) {
225
+ const dir = credentialsDir(home);
226
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
227
+ if (process.platform !== "win32") {
228
+ try {
229
+ fs.chmodSync(dir, 448);
230
+ } catch {
231
+ }
232
+ }
233
+ const file = credentialsPath(home);
234
+ fs.writeFileSync(file, `${JSON.stringify(creds, null, 2)}
235
+ `, {
236
+ mode: 384
237
+ });
238
+ if (process.platform !== "win32") {
239
+ fs.chmodSync(file, 384);
240
+ }
241
+ }
242
+ function clear(home = os.homedir()) {
243
+ fs.rmSync(credentialsPath(home), { force: true });
244
+ }
245
+ function decodeJwtPayload(token) {
246
+ const parts = token.split(".");
247
+ if (parts.length !== 3) {
248
+ throw new Error("Invalid JWT: expected three dot-separated segments");
249
+ }
250
+ const segment = parts[1];
251
+ if (!segment) throw new Error("Invalid JWT: empty payload segment");
252
+ const padded = segment + "=".repeat((4 - segment.length % 4) % 4);
253
+ const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
254
+ const json = Buffer.from(base64, "base64").toString("utf8");
255
+ return JSON.parse(json);
256
+ }
257
+ function isExpired(creds, nowSeconds = Math.floor(Date.now() / 1e3)) {
258
+ return creds.expires_at <= nowSeconds;
259
+ }
260
+ function isENOENT(err) {
261
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
262
+ }
263
+
264
+ // src/config.ts
265
+ import * as fs2 from "fs";
266
+ import * as path2 from "path";
267
+ var NAV_BASE_DEFAULT = "https://nav.maker.co";
268
+ var StoreResolutionError = class extends Error {
269
+ constructor(message) {
270
+ super(message);
271
+ this.name = "StoreResolutionError";
272
+ }
273
+ };
274
+ function resolveStore(input = {}) {
275
+ const flag = input.flag?.trim();
276
+ if (flag) return flag;
277
+ const fromRc = findGsrc(input.cwd ?? process.cwd());
278
+ if (fromRc) return fromRc;
279
+ const env = input.env ?? process.env;
280
+ const fromEnv = env.GS_STORE?.trim();
281
+ if (fromEnv) return fromEnv;
282
+ throw new StoreResolutionError(
283
+ 'No store selected. Pass `--store <slug>`, set GS_STORE, or create a .gsrc file with {"store":"..."}.'
284
+ );
285
+ }
286
+ function findGsrc(cwd) {
287
+ let dir = path2.resolve(cwd);
288
+ const root = path2.parse(dir).root;
289
+ while (true) {
290
+ const candidate = path2.join(dir, ".gsrc");
291
+ if (fs2.existsSync(candidate)) {
292
+ try {
293
+ const raw = fs2.readFileSync(candidate, "utf8");
294
+ const parsed = JSON.parse(raw);
295
+ if (typeof parsed.store === "string" && parsed.store.trim()) {
296
+ return parsed.store.trim();
297
+ }
298
+ } catch {
299
+ }
300
+ }
301
+ if (dir === root) return null;
302
+ const parent = path2.dirname(dir);
303
+ if (parent === dir) return null;
304
+ dir = parent;
305
+ }
306
+ }
307
+ function apiBaseFor(slug, env = process.env) {
308
+ const override = env.GS_API_BASE?.trim();
309
+ if (override) return stripTrailingSlash(override);
310
+ return `https://${slug}.greatstore.ai`;
311
+ }
312
+ function navBase(env = process.env) {
313
+ const override = env.GS_NAV_BASE?.trim();
314
+ if (override) return stripTrailingSlash(override);
315
+ return NAV_BASE_DEFAULT;
316
+ }
317
+ function stripTrailingSlash(s) {
318
+ return s.endsWith("/") ? s.slice(0, -1) : s;
319
+ }
320
+
321
+ // src/http.ts
322
+ var HttpError = class extends Error {
323
+ constructor(status, message, code, detail) {
324
+ super(message);
325
+ this.status = status;
326
+ this.code = code;
327
+ this.detail = detail;
328
+ this.name = "HttpError";
329
+ }
330
+ };
331
+ var AuthRequiredError = class extends Error {
332
+ constructor(message = "Not logged in. Run `gs login`.") {
333
+ super(message);
334
+ this.name = "AuthRequiredError";
335
+ }
336
+ };
337
+ async function request(url, options = {}) {
338
+ const interactive = options.interactive ?? true;
339
+ const stored = read();
340
+ if (!stored) throw new AuthRequiredError();
341
+ try {
342
+ return await sendOnce(url, options, stored.access_token);
343
+ } catch (err) {
344
+ if (!interactive || !(err instanceof HttpError) || err.status !== 401) {
345
+ throw err;
346
+ }
347
+ const refreshed = await reauthenticate();
348
+ return await sendOnce(url, options, refreshed.access_token);
349
+ }
350
+ }
351
+ async function sendOnce(url, options, accessToken) {
352
+ const headers = {
353
+ accept: "application/json",
354
+ authorization: `Bearer ${accessToken}`,
355
+ ...options.headers ?? {}
356
+ };
357
+ let body;
358
+ if (options.multipart && options.body !== void 0) {
359
+ throw new Error("request(): pass either `body` or `multipart`, not both");
360
+ }
361
+ if (options.multipart) {
362
+ body = options.multipart;
363
+ delete headers["content-type"];
364
+ } else if (options.body !== void 0) {
365
+ body = JSON.stringify(options.body);
366
+ headers["content-type"] = "application/json";
367
+ }
368
+ const init = {
369
+ method: options.method ?? "GET",
370
+ headers
371
+ };
372
+ if (body !== void 0) init.body = body;
373
+ const res = await fetch(url, init);
374
+ if (res.status >= 200 && res.status < 300) {
375
+ if (res.status === 204) return void 0;
376
+ const ct = res.headers.get("content-type") ?? "";
377
+ if (ct.includes("application/json")) {
378
+ return await res.json();
379
+ }
380
+ return await res.text();
381
+ }
382
+ const text = await res.text();
383
+ let parsed = {};
384
+ try {
385
+ parsed = JSON.parse(text);
386
+ } catch {
387
+ }
388
+ const message = parsed.error?.trim() || parsed.detail?.trim() || text.trim() || `HTTP ${res.status}`;
389
+ throw new HttpError(res.status, message, parsed.code, parsed.detail);
390
+ }
391
+ async function reauthenticate() {
392
+ process.stderr.write("Access token expired \u2014 re-running sign-in.\n");
393
+ try {
394
+ const { token } = await captureLoopbackToken({ navBaseUrl: navBase() });
395
+ const next = buildCredentials(token);
396
+ write(next);
397
+ return next;
398
+ } catch (err) {
399
+ if (err instanceof LoopbackError) {
400
+ throw new AuthRequiredError(err.message);
401
+ }
402
+ throw err;
403
+ }
404
+ }
405
+ function buildCredentials(token) {
406
+ const claims = decodeJwtPayload(token);
407
+ const sub = readString(claims, "sub");
408
+ if (!sub) throw new Error("Token is missing a `sub` claim.");
409
+ const exp = readNumber(claims, "exp");
410
+ return {
411
+ access_token: token,
412
+ expires_at: exp ?? Math.floor(Date.now() / 1e3) + 60 * 60,
413
+ sub,
414
+ ...maybeString(claims, "email", "email"),
415
+ ...maybeString(claims, "accountId", "accountId"),
416
+ ...maybeString(claims, "name", "name"),
417
+ ...maybeString(claims, "surname", "surname"),
418
+ ...maybeBool(claims, "superUser", "superUser")
419
+ };
420
+ }
421
+ function readString(obj, key) {
422
+ const v = obj[key];
423
+ return typeof v === "string" ? v : void 0;
424
+ }
425
+ function readNumber(obj, key) {
426
+ const v = obj[key];
427
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
428
+ }
429
+ function maybeString(obj, src, dst) {
430
+ const v = readString(obj, src);
431
+ return v ? { [dst]: v } : {};
432
+ }
433
+ function maybeBool(obj, src, dst) {
434
+ const v = obj[src];
435
+ if (v === true) return { [dst]: true };
436
+ if (typeof v === "string" && v.toLowerCase() === "true") return { [dst]: true };
437
+ return {};
438
+ }
439
+
440
+ // src/commands/login.ts
441
+ async function loginCommand() {
442
+ process.stdout.write("Opening browser for sign-in\u2026\n");
443
+ const { token } = await captureLoopbackToken({ navBaseUrl: navBase() });
444
+ const next = buildCredentials(token);
445
+ write(next);
446
+ const who = next.email ?? next.sub;
447
+ process.stdout.write(`Logged in as ${who}.
448
+ `);
449
+ }
450
+
451
+ // src/commands/logout.ts
452
+ function logoutCommand() {
453
+ const existed = read() !== null;
454
+ clear();
455
+ process.stdout.write(existed ? "Logged out.\n" : "Not logged in.\n");
456
+ }
457
+
458
+ // src/commands/whoami.ts
459
+ function whoamiCommand() {
460
+ const stored = read();
461
+ if (!stored) {
462
+ process.stderr.write("Not logged in. Run `gs login`.\n");
463
+ return 1;
464
+ }
465
+ const expired = isExpired(stored);
466
+ const lines = [
467
+ `sub: ${stored.sub}`,
468
+ `email: ${stored.email ?? "(none)"}`,
469
+ `accountId: ${stored.accountId ?? "(none)"}`,
470
+ `superUser: ${stored.superUser ?? false}`,
471
+ `name: ${[stored.name, stored.surname].filter(Boolean).join(" ") || "(none)"}`,
472
+ `expires: ${new Date(stored.expires_at * 1e3).toISOString()}${expired ? " (EXPIRED)" : ""}`
473
+ ];
474
+ process.stdout.write(lines.join("\n") + "\n");
475
+ return expired ? 1 : 0;
476
+ }
477
+
478
+ // src/commands/list.ts
479
+ async function listCommand(args) {
480
+ const slug = resolveStore({ flag: flagString(args.flags, "store") });
481
+ const url = `${apiBaseFor(slug)}/api/builder/components`;
482
+ const data = await request(url);
483
+ if (flagBool(args.flags, "json")) {
484
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
485
+ return;
486
+ }
487
+ if (data.components.length === 0) {
488
+ process.stdout.write(`(no components in ${slug})
489
+ `);
490
+ return;
491
+ }
492
+ const rows = data.components.map((c) => ({
493
+ name: c.name,
494
+ draft: c.draft ? `v${c.draft.version}` : "\u2014",
495
+ live: c.live ? `v${c.live.version}` : "\u2014",
496
+ updated: c.live?.updatedAt ?? c.draft?.updatedAt ?? ""
497
+ }));
498
+ const widths = {
499
+ name: Math.max(4, ...rows.map((r) => r.name.length)),
500
+ draft: Math.max(5, ...rows.map((r) => r.draft.length)),
501
+ live: Math.max(4, ...rows.map((r) => r.live.length))
502
+ };
503
+ const header = `${pad("NAME", widths.name)} ${pad("DRAFT", widths.draft)} ${pad("LIVE", widths.live)} UPDATED`;
504
+ process.stdout.write(header + "\n");
505
+ for (const r of rows) {
506
+ process.stdout.write(
507
+ `${pad(r.name, widths.name)} ${pad(r.draft, widths.draft)} ${pad(r.live, widths.live)} ${r.updated}
508
+ `
509
+ );
510
+ }
511
+ }
512
+ function pad(s, width) {
513
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
514
+ }
515
+
516
+ // src/commands/pull.ts
517
+ import * as fs3 from "fs";
518
+ import * as path3 from "path";
519
+ async function pullCommand(args) {
520
+ const name = args.positional[0];
521
+ if (!name) {
522
+ throw new Error("Usage: gs pull <name> [--draft|--live|--version N] [-o <dir>]");
523
+ }
524
+ const slug = resolveStore({ flag: flagString(args.flags, "store") });
525
+ const out = flagString(args.flags, "out") ?? ".";
526
+ const query = buildRevisionQuery(args);
527
+ const url = `${apiBaseFor(slug)}/api/builder/components/${encodeURIComponent(name)}${query}`;
528
+ const data = await request(url);
529
+ fs3.mkdirSync(out, { recursive: true });
530
+ fs3.writeFileSync(
531
+ path3.join(out, "manifest.json"),
532
+ JSON.stringify(data.manifest, null, 2) + "\n"
533
+ );
534
+ fs3.writeFileSync(path3.join(out, "bundle.js"), data.bundle);
535
+ process.stdout.write(
536
+ `Pulled ${name} v${data.version} \u2192 ${path3.resolve(out)}/{manifest.json, bundle.js}
537
+ `
538
+ );
539
+ }
540
+ function buildRevisionQuery(args) {
541
+ const version = flagString(args.flags, "version");
542
+ if (version) return `?version=${encodeURIComponent(version)}`;
543
+ if (flagBool(args.flags, "draft")) return `?revision=draft`;
544
+ if (flagBool(args.flags, "live")) return `?revision=live`;
545
+ return "";
546
+ }
547
+
548
+ // src/commands/push.ts
549
+ import * as fs4 from "fs";
550
+ import * as path4 from "path";
551
+ async function pushCommand(args) {
552
+ const slug = resolveStore({ flag: flagString(args.flags, "store") });
553
+ const manifestPath = path4.resolve(flagString(args.flags, "manifest") ?? "manifest.json");
554
+ const bundlePath = path4.resolve(flagString(args.flags, "bundle") ?? "bundle.js");
555
+ if (!fs4.existsSync(manifestPath)) {
556
+ throw new Error(`Manifest not found: ${manifestPath}`);
557
+ }
558
+ if (!fs4.existsSync(bundlePath)) {
559
+ throw new Error(`Bundle not found: ${bundlePath} (did you run \`npm run build\`?)`);
560
+ }
561
+ const manifestText = fs4.readFileSync(manifestPath, "utf8");
562
+ let manifestName;
563
+ try {
564
+ const parsed = JSON.parse(manifestText);
565
+ if (typeof parsed.name === "string") manifestName = parsed.name;
566
+ } catch (err) {
567
+ throw new Error(`Manifest is not valid JSON: ${err.message}`);
568
+ }
569
+ const name = args.positional[0] ?? manifestName;
570
+ if (!name) {
571
+ throw new Error("Could not determine component name. Pass it positionally or set `name` in manifest.json.");
572
+ }
573
+ if (manifestName && manifestName !== name) {
574
+ throw new Error(
575
+ `Manifest name "${manifestName}" does not match argument "${name}". The server will reject this.`
576
+ );
577
+ }
578
+ const bundleText = fs4.readFileSync(bundlePath, "utf8");
579
+ const form = new FormData();
580
+ form.append("manifest", new Blob([manifestText], { type: "application/json" }), "manifest.json");
581
+ form.append("bundle", new Blob([bundleText], { type: "text/javascript" }), "bundle.js");
582
+ const url = `${apiBaseFor(slug)}/api/builder/components/${encodeURIComponent(name)}`;
583
+ const data = await request(url, { method: "POST", multipart: form });
584
+ process.stdout.write(
585
+ `Pushed ${name} v${data.version} (draft) to ${slug}. Run \`gs publish ${name}\` to promote.
586
+ `
587
+ );
588
+ }
589
+
590
+ // src/commands/publish.ts
591
+ async function publishCommand(args) {
592
+ const name = args.positional[0];
593
+ if (!name) throw new Error("Usage: gs publish <name> [--version N]");
594
+ const slug = resolveStore({ flag: flagString(args.flags, "store") });
595
+ const url = `${apiBaseFor(slug)}/api/builder/components/${encodeURIComponent(name)}/publish`;
596
+ const versionFlag = flagString(args.flags, "version");
597
+ let body = void 0;
598
+ if (versionFlag !== void 0) {
599
+ const n = Number.parseInt(versionFlag, 10);
600
+ if (!Number.isInteger(n) || n < 1) {
601
+ throw new Error(`Invalid --version: ${versionFlag}`);
602
+ }
603
+ body = { version: n };
604
+ } else {
605
+ body = {};
606
+ }
607
+ const data = await request(url, { method: "POST", body });
608
+ process.stdout.write(`Published ${name} (live = v${data.live_version}).
609
+ `);
610
+ }
611
+
612
+ // src/commands/unpublish.ts
613
+ async function unpublishCommand(args) {
614
+ const name = args.positional[0];
615
+ if (!name) throw new Error("Usage: gs unpublish <name>");
616
+ const slug = resolveStore({ flag: flagString(args.flags, "store") });
617
+ const url = `${apiBaseFor(slug)}/api/builder/components/${encodeURIComponent(name)}/unpublish`;
618
+ await request(url, { method: "POST", body: {} });
619
+ process.stdout.write(`Unpublished ${name}. The draft and version history are retained.
620
+ `);
621
+ }
622
+
623
+ // src/commands/delete.ts
624
+ import * as readline from "readline";
625
+ async function deleteCommand(args) {
626
+ const name = args.positional[0];
627
+ if (!name) throw new Error("Usage: gs delete <name> [--yes]");
628
+ const slug = resolveStore({ flag: flagString(args.flags, "store") });
629
+ if (!flagBool(args.flags, "yes")) {
630
+ const confirmed = await prompt(
631
+ `Soft-delete component "${name}" in store "${slug}"? Type "yes" to confirm: `
632
+ );
633
+ if (confirmed.trim() !== "yes") {
634
+ process.stdout.write("Aborted.\n");
635
+ return;
636
+ }
637
+ }
638
+ const url = `${apiBaseFor(slug)}/api/builder/components/${encodeURIComponent(name)}`;
639
+ await request(url, { method: "DELETE" });
640
+ process.stdout.write(`Deleted ${name}. (R2 objects retained; history preserved.)
641
+ `);
642
+ }
643
+ function prompt(question) {
644
+ return new Promise((resolve5) => {
645
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
646
+ rl.question(question, (answer) => {
647
+ rl.close();
648
+ resolve5(answer);
649
+ });
650
+ });
651
+ }
652
+
653
+ // src/commands/init.ts
654
+ import * as fs5 from "fs";
655
+ import * as path5 from "path";
656
+ var NAME_REGEX = /^[a-z][a-z0-9_]*$/;
657
+ function initCommand(args) {
658
+ const name = args.positional[0];
659
+ if (!name) {
660
+ throw new Error("Usage: gs init <name> [--out <dir>] [--store <slug>] [--force]");
661
+ }
662
+ if (!NAME_REGEX.test(name)) {
663
+ throw new Error(`Invalid component name: "${name}" (must match ${NAME_REGEX}).`);
664
+ }
665
+ const outRel = flagString(args.flags, "out") ?? name;
666
+ const out = path5.resolve(outRel);
667
+ const force = flagBool(args.flags, "force");
668
+ const storeFlag = flagString(args.flags, "store");
669
+ ensureWritable(out, force);
670
+ fs5.mkdirSync(out, { recursive: true });
671
+ for (const [relPath, content] of files({ name, store: storeFlag })) {
672
+ const full = path5.join(out, relPath);
673
+ fs5.mkdirSync(path5.dirname(full), { recursive: true });
674
+ fs5.writeFileSync(full, content);
675
+ }
676
+ process.stdout.write(
677
+ [
678
+ `Scaffolded "${name}" in ${out}.`,
679
+ "",
680
+ "Next steps:",
681
+ ` cd ${path5.relative(process.cwd(), out) || "."}`,
682
+ " npm install",
683
+ " npm run build",
684
+ " gs push",
685
+ ""
686
+ ].join("\n")
687
+ );
688
+ }
689
+ function ensureWritable(dir, force) {
690
+ if (!fs5.existsSync(dir)) return;
691
+ const entries = fs5.readdirSync(dir);
692
+ if (entries.length === 0) return;
693
+ if (force) return;
694
+ throw new Error(
695
+ `Refusing to scaffold into non-empty directory ${dir}. Pass --force to override.`
696
+ );
697
+ }
698
+ function files(opts) {
699
+ const { name, store } = opts;
700
+ return [
701
+ ["manifest.json", manifest(name)],
702
+ ["component.tsx", component(name)],
703
+ ["vite.config.ts", viteConfig()],
704
+ ["tsconfig.json", tsconfig()],
705
+ ["package.json", packageJson(name)],
706
+ [".gsrc", gsrc(store)],
707
+ [".gitignore", gitignore()],
708
+ ["README.md", readme(name)]
709
+ ];
710
+ }
711
+ function manifest(name) {
712
+ return JSON.stringify(
713
+ {
714
+ name,
715
+ description: `Renders the ${name} widget.`,
716
+ displayMode: "inline",
717
+ inputSchema: {
718
+ type: "object",
719
+ properties: {}
720
+ }
721
+ },
722
+ null,
723
+ 2
724
+ ) + "\n";
725
+ }
726
+ function component(name) {
727
+ return `import React from "react";
728
+
729
+ interface Props {
730
+ // Add fields here matching manifest.json#inputSchema.properties.
731
+ }
732
+
733
+ export default function ${pascal(name)}(_props: Props): React.ReactElement {
734
+ return (
735
+ <div style={{ padding: "1rem", border: "1px solid #ddd", borderRadius: 8 }}>
736
+ <strong>${name}</strong> \u2014 hello from your component!
737
+ </div>
738
+ );
739
+ }
740
+ `;
741
+ }
742
+ function viteConfig() {
743
+ return `import { defineConfig } from "vite";
744
+ import react from "@vitejs/plugin-react";
745
+
746
+ // Builds a single ESM bundle suitable for \`gs push\`. React + ReactDOM
747
+ // are externalized \u2014 the GreatStore runtime provides them at load time.
748
+ export default defineConfig({
749
+ plugins: [react()],
750
+ build: {
751
+ lib: {
752
+ entry: "component.tsx",
753
+ formats: ["es"],
754
+ fileName: () => "bundle.js",
755
+ },
756
+ outDir: ".",
757
+ emptyOutDir: false,
758
+ rollupOptions: {
759
+ external: ["react", "react-dom", "react/jsx-runtime"],
760
+ output: { entryFileNames: "bundle.js" },
761
+ },
762
+ minify: true,
763
+ sourcemap: false,
764
+ },
765
+ });
766
+ `;
767
+ }
768
+ function tsconfig() {
769
+ return JSON.stringify(
770
+ {
771
+ compilerOptions: {
772
+ target: "ES2022",
773
+ module: "ESNext",
774
+ moduleResolution: "Bundler",
775
+ jsx: "react-jsx",
776
+ lib: ["ES2022", "DOM"],
777
+ strict: true,
778
+ esModuleInterop: true,
779
+ skipLibCheck: true,
780
+ isolatedModules: true,
781
+ noEmit: true
782
+ },
783
+ include: ["component.tsx", "vite.config.ts"]
784
+ },
785
+ null,
786
+ 2
787
+ ) + "\n";
788
+ }
789
+ function packageJson(name) {
790
+ return JSON.stringify(
791
+ {
792
+ name,
793
+ version: "0.0.1",
794
+ private: true,
795
+ type: "module",
796
+ scripts: {
797
+ build: "vite build",
798
+ push: "vite build && gs push"
799
+ },
800
+ dependencies: {
801
+ react: "^19.0.0",
802
+ "react-dom": "^19.0.0"
803
+ },
804
+ devDependencies: {
805
+ "@types/react": "^19.0.0",
806
+ "@types/react-dom": "^19.0.0",
807
+ "@vitejs/plugin-react": "^4.3.0",
808
+ typescript: "^5.6.0",
809
+ vite: "^5.4.0"
810
+ }
811
+ },
812
+ null,
813
+ 2
814
+ ) + "\n";
815
+ }
816
+ function gsrc(store) {
817
+ return JSON.stringify({ store: store ?? "<your-store-slug>" }, null, 2) + "\n";
818
+ }
819
+ function gitignore() {
820
+ return ["node_modules/", "bundle.js", "*.tsbuildinfo", ".DS_Store", ""].join("\n");
821
+ }
822
+ function readme(name) {
823
+ return `# ${name}
824
+
825
+ A GreatStore custom component scaffolded with \`gs init\`.
826
+
827
+ \`\`\`
828
+ npm install
829
+ npm run build # produces bundle.js
830
+ gs push # uploads as a draft revision
831
+ gs publish ${name}
832
+ \`\`\`
833
+
834
+ Edit \`component.tsx\` for the UI and \`manifest.json\` for the metadata
835
+ that the LLM sees (especially \`description\` and \`inputSchema\`).
836
+ `;
837
+ }
838
+ function pascal(name) {
839
+ return name.split(/[_-]/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
840
+ }
841
+
842
+ // src/index.ts
843
+ var VERSION = "0.0.1";
844
+ var HELP = `gs \u2014 GreatStore CLI (v${VERSION})
845
+
846
+ Usage:
847
+ gs <command> [args] [flags]
848
+
849
+ Commands:
850
+ login Open browser, capture nav token, persist credentials.
851
+ logout Wipe ~/.greatstore/credentials.json.
852
+ whoami Print the identity stored locally.
853
+ init <name> Scaffold a component project.
854
+ list List components in the current store.
855
+ pull <name> Download manifest.json + bundle.js.
856
+ push [<name>] Upload manifest.json + bundle.js as a new draft.
857
+ publish <name> Promote the draft (or --version N) to live.
858
+ unpublish <name> Clear the live pointer.
859
+ delete <name> Soft-delete the component.
860
+
861
+ Common flags:
862
+ --store <slug> Target store (else .gsrc or GS_STORE).
863
+ --json Machine-readable output for \`list\`.
864
+ --draft | --live | --version N Revision selector for \`pull\`.
865
+ -o, --out <dir> Output directory for \`pull\` / \`init\`.
866
+ --manifest <path> Path to manifest for \`push\`.
867
+ --bundle <path> Path to bundle for \`push\`.
868
+ --force Overwrite for \`init\`; skip confirmation for \`delete\`.
869
+ --yes Skip confirmation for \`delete\`.
870
+ -h, --help Show this help.
871
+ -v, --version Print version.
872
+
873
+ Environment:
874
+ GS_STORE Fallback for --store.
875
+ GS_API_BASE Override API base (full URL, no trailing slash).
876
+ GS_NAV_BASE Override nav OAuth base.
877
+ GS_BROWSER_CMD Override the browser-open command.
878
+ `;
879
+ async function main() {
880
+ const argv = process.argv.slice(2);
881
+ const parsed = parseArgs(argv);
882
+ if (flagBool(parsed.flags, "version")) {
883
+ process.stdout.write(`gs v${VERSION}
884
+ `);
885
+ return 0;
886
+ }
887
+ if (flagBool(parsed.flags, "help") || parsed.command === "help" || parsed.command === null) {
888
+ process.stdout.write(HELP);
889
+ return 0;
890
+ }
891
+ switch (parsed.command) {
892
+ case "login":
893
+ await loginCommand();
894
+ return 0;
895
+ case "logout":
896
+ logoutCommand();
897
+ return 0;
898
+ case "whoami":
899
+ return whoamiCommand();
900
+ case "init":
901
+ initCommand(parsed);
902
+ return 0;
903
+ case "list":
904
+ await listCommand(parsed);
905
+ return 0;
906
+ case "pull":
907
+ await pullCommand(parsed);
908
+ return 0;
909
+ case "push":
910
+ await pushCommand(parsed);
911
+ return 0;
912
+ case "publish":
913
+ await publishCommand(parsed);
914
+ return 0;
915
+ case "unpublish":
916
+ await unpublishCommand(parsed);
917
+ return 0;
918
+ case "delete":
919
+ await deleteCommand(parsed);
920
+ return 0;
921
+ default:
922
+ process.stderr.write(`Unknown command: ${parsed.command}
923
+
924
+ ${HELP}`);
925
+ return 1;
926
+ }
927
+ }
928
+ main().then((code) => process.exit(code)).catch((err) => {
929
+ if (err instanceof AuthRequiredError) {
930
+ process.stderr.write(`${err.message}
931
+ `);
932
+ } else if (err instanceof HttpError) {
933
+ process.stderr.write(`Error (HTTP ${err.status}): ${err.message}
934
+ `);
935
+ if (err.detail && err.detail !== err.message) {
936
+ process.stderr.write(` ${err.detail}
937
+ `);
938
+ }
939
+ } else if (err instanceof StoreResolutionError || err instanceof LoopbackError) {
940
+ process.stderr.write(`Error: ${err.message}
941
+ `);
942
+ } else if (err instanceof Error) {
943
+ process.stderr.write(`Error: ${err.message}
944
+ `);
945
+ } else {
946
+ process.stderr.write(`Error: ${String(err)}
947
+ `);
948
+ }
949
+ process.exit(1);
950
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@greatstore/cli",
3
+ "version": "0.0.1",
4
+ "description": "CLI for authoring and shipping GreatStore custom components.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "gs": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.14.0",
26
+ "tsup": "^8.3.0",
27
+ "typescript": "^5.8.2",
28
+ "vitest": "^2.1.0"
29
+ }
30
+ }