@jrc03c/gt-cli 0.1.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 +112 -0
  2. package/dist/index.js +876 -0
  3. package/package.json +43 -0
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # gt-cli
2
+
3
+ A TypeScript CLI for the [GuidedTrack](https://guidedtrack.com) web API.
4
+
5
+ ## Install
6
+
7
+ Requires Node.js >= 20 and [pnpm](https://pnpm.io/).
8
+
9
+ ```bash
10
+ pnpm install
11
+ pnpm build
12
+ pnpm link --global # makes `gt` available globally
13
+ ```
14
+
15
+ ## Authentication
16
+
17
+ Auth is resolved in this order:
18
+
19
+ 1. **Environment variables**: `GT_EMAIL` and `GT_PASSWORD`
20
+ 2. **Config file**: `email` and `password` fields in `gt.config.json`
21
+ 3. **Interactive prompt**: falls back to prompting if neither is set
22
+
23
+ ## Environment
24
+
25
+ Set `GT_ENV` to target different GuidedTrack environments:
26
+
27
+ | Value | Host |
28
+ |-------|------|
29
+ | `development` | `https://localhost:3000` |
30
+ | `stage` | `https://guidedtrack-stage.herokuapp.com` |
31
+ | `production` (default) | `https://www.guidedtrack.com` |
32
+
33
+ ## Commands
34
+
35
+ ### Project workflow
36
+
37
+ ```bash
38
+ gt init # Create gt.config.json by scanning for program files
39
+ gt config # Print current project configuration
40
+ gt push # Upload all local program files to the server
41
+ gt push --only <name> # Push a single program
42
+ gt push --build # Push and build
43
+ gt pull # Download all program sources from the server
44
+ gt pull --only <name> # Pull a single program
45
+ gt create [names...] # Create new programs on the server
46
+ gt build # Compile programs and report errors
47
+ gt compare [args...] # Compare programs using gt-compare
48
+ ```
49
+
50
+ ### Program management
51
+
52
+ ```bash
53
+ gt program list # List all programs
54
+ gt program find <query> # Search programs by name
55
+ gt program get <name> # Fetch program metadata (JSON)
56
+ gt program source <name> # Fetch program source code
57
+ gt program build <name> # Build a specific program
58
+ gt program delete <name> # Delete a program (with confirmation)
59
+ gt program delete <name> -y # Delete without confirmation
60
+ ```
61
+
62
+ ### Program data
63
+
64
+ ```bash
65
+ gt program data <name> # Download program data as CSV (stdout)
66
+ gt program csv <name> # Alias for `program data`
67
+ gt program data <name> -o f.csv # Save to file
68
+ ```
69
+
70
+ ### Browser shortcuts
71
+
72
+ ```bash
73
+ gt program view <name> # Open program edit page
74
+ gt program preview <name> # Open program preview
75
+ gt program run <name> # Open program run page
76
+ ```
77
+
78
+ ### Generic API access
79
+
80
+ ```bash
81
+ gt request <path> # GET any API endpoint
82
+ gt request <path> -X POST -d '{"key":"val"}' # POST with JSON body
83
+ gt request <path> -H "X-Custom:value" # Add custom headers
84
+ ```
85
+
86
+ ## Configuration
87
+
88
+ `gt init` creates a `gt.config.json` in the current directory:
89
+
90
+ ```json
91
+ {
92
+ "programs": {
93
+ "my-program": {
94
+ "id": 12345,
95
+ "key": "abc1234"
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ This file is gitignored. It maps local program file names to their server-side IDs and keys.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ pnpm dev [command] [args] # Run via tsx (no build step)
107
+ pnpm build # Bundle to dist/
108
+ pnpm test # Run tests
109
+ pnpm test:watch # Watch mode
110
+ pnpm lint # ESLint
111
+ pnpm format # Prettier
112
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,876 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/build.ts
7
+ import { readFile as readFile2 } from "fs/promises";
8
+ import { resolve as resolve2 } from "path";
9
+
10
+ // src/types.ts
11
+ var ENVIRONMENT_HOSTS = {
12
+ development: "https://localhost:3000",
13
+ stage: "https://guidedtrack-stage.herokuapp.com",
14
+ production: "https://www.guidedtrack.com"
15
+ };
16
+
17
+ // src/lib/api.ts
18
+ function buildAuthHeader(credentials) {
19
+ const encoded = Buffer.from(
20
+ `${credentials.email}:${credentials.password}`
21
+ ).toString("base64");
22
+ return `Basic ${encoded}`;
23
+ }
24
+ async function apiRequest(path, options) {
25
+ const env = options.environment ?? getEnvironment();
26
+ const host = ENVIRONMENT_HOSTS[env];
27
+ const url = `${host}${path}`;
28
+ const headers = {
29
+ Authorization: buildAuthHeader(options.credentials),
30
+ ...options.headers
31
+ };
32
+ if (options.body) {
33
+ headers["Content-Type"] = "application/json";
34
+ }
35
+ const response = await fetch(url, {
36
+ method: options.method ?? "GET",
37
+ headers,
38
+ body: options.body ? JSON.stringify(options.body) : void 0
39
+ });
40
+ if (!response.ok) {
41
+ throw new Error(
42
+ `API request failed: ${response.status} ${response.statusText}`
43
+ );
44
+ }
45
+ return response;
46
+ }
47
+ async function findProgramByTitle(title, credentials, environment) {
48
+ const query = encodeURIComponent(title);
49
+ const response = await apiRequest(`/programs.json?query=${query}`, {
50
+ credentials,
51
+ environment
52
+ });
53
+ const programs = await response.json();
54
+ return programs.find((p) => p.name === title) ?? null;
55
+ }
56
+ async function getProgram(id, credentials, environment) {
57
+ try {
58
+ const response = await apiRequest(`/programs/${id}.json`, {
59
+ credentials,
60
+ environment
61
+ });
62
+ return await response.json();
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ async function findProgramByKey(key, credentials, environment) {
68
+ const response = await apiRequest("/programs.json", {
69
+ credentials,
70
+ environment
71
+ });
72
+ const programs = await response.json();
73
+ return programs.find((p) => p.key === key) ?? null;
74
+ }
75
+ async function listPrograms(credentials, environment) {
76
+ const response = await apiRequest("/programs.json", {
77
+ credentials,
78
+ environment
79
+ });
80
+ return await response.json();
81
+ }
82
+ async function fetchProgramSource(programId, credentials, environment) {
83
+ const env = environment ?? getEnvironment();
84
+ const host = ENVIRONMENT_HOSTS[env];
85
+ const response = await fetch(`${host}/programs/${programId}/edit`, {
86
+ headers: { Authorization: buildAuthHeader(credentials) }
87
+ });
88
+ if (!response.ok) {
89
+ throw new Error(
90
+ `Failed to fetch program source: ${response.status} ${response.statusText}`
91
+ );
92
+ }
93
+ const html = await response.text();
94
+ const match = html.match(
95
+ /<textarea[^>]*name="contents"[^>]*>([\s\S]*?)<\/textarea>/
96
+ );
97
+ if (!match) {
98
+ throw new Error("Could not extract program source from edit page");
99
+ }
100
+ return match[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
101
+ }
102
+ function getEnvironment() {
103
+ const env = process.env.GT_ENV ?? "production";
104
+ if (env !== "development" && env !== "stage" && env !== "production") {
105
+ throw new Error(
106
+ `Invalid GT_ENV: "${env}". Must be development, stage, or production.`
107
+ );
108
+ }
109
+ return env;
110
+ }
111
+
112
+ // src/lib/auth.ts
113
+ import { createInterface } from "readline";
114
+
115
+ // src/lib/config.ts
116
+ import { readFile, writeFile } from "fs/promises";
117
+ import { resolve } from "path";
118
+ var CONFIG_FILENAME = "gt.config.json";
119
+ async function loadConfig(dir) {
120
+ const configPath = resolve(dir ?? process.cwd(), CONFIG_FILENAME);
121
+ try {
122
+ const raw = await readFile(configPath, "utf-8");
123
+ return JSON.parse(raw);
124
+ } catch (err) {
125
+ if (err.code === "ENOENT") {
126
+ return {};
127
+ }
128
+ throw new Error(`Failed to read ${CONFIG_FILENAME}: ${err}`);
129
+ }
130
+ }
131
+ async function saveConfig(config, dir) {
132
+ const configPath = resolve(dir ?? process.cwd(), CONFIG_FILENAME);
133
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
134
+ }
135
+
136
+ // src/lib/auth.ts
137
+ function prompt(question) {
138
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
139
+ return new Promise((resolve7) => {
140
+ rl2.question(question, (answer) => {
141
+ rl2.close();
142
+ resolve7(answer);
143
+ });
144
+ });
145
+ }
146
+ function promptSecret(question) {
147
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
148
+ const originalWrite = process.stdout.write.bind(process.stdout);
149
+ let muted = false;
150
+ process.stdout.write = ((...args) => {
151
+ if (muted) {
152
+ return true;
153
+ }
154
+ return originalWrite(...args);
155
+ });
156
+ return new Promise((resolve7) => {
157
+ rl2.question(question, (answer) => {
158
+ muted = false;
159
+ process.stdout.write = originalWrite;
160
+ originalWrite("\n");
161
+ rl2.close();
162
+ resolve7(answer);
163
+ });
164
+ muted = true;
165
+ });
166
+ }
167
+ async function promptCredentials() {
168
+ const email = await prompt("GT email: ");
169
+ const password = await promptSecret("GT password: ");
170
+ return { email, password };
171
+ }
172
+ async function resolveCredentials() {
173
+ const email = process.env.GT_EMAIL;
174
+ const password = process.env.GT_PASSWORD;
175
+ if (email && password) {
176
+ return { email, password };
177
+ }
178
+ const config = await loadConfig();
179
+ if (config.email && config.password) {
180
+ return { email: config.email, password: config.password };
181
+ }
182
+ if (process.stdin.isTTY) {
183
+ return promptCredentials();
184
+ }
185
+ throw new Error(
186
+ "No credentials found. Provide credentials via:\n - GT_EMAIL and GT_PASSWORD environment variables\n - email and password fields in gt.config.json\n - Run in a terminal for interactive prompt"
187
+ );
188
+ }
189
+
190
+ // src/lib/jobs.ts
191
+ var DEFAULT_POLL_INTERVAL_MS = 3e3;
192
+ async function pollJob(jobId, credentials, options) {
193
+ const interval = options?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
194
+ while (true) {
195
+ const response = await apiRequest(`/delayed_jobs/${jobId}`, {
196
+ credentials,
197
+ environment: options?.environment
198
+ });
199
+ const job = await response.json();
200
+ if (job.status !== "running") {
201
+ return job;
202
+ }
203
+ await new Promise((resolve7) => setTimeout(resolve7, interval));
204
+ }
205
+ }
206
+
207
+ // src/lib/build.ts
208
+ async function getEmbedInfo(key, credentials, environment) {
209
+ const response = await apiRequest(`/programs/${key}/embed`, {
210
+ credentials,
211
+ environment
212
+ });
213
+ return await response.json();
214
+ }
215
+ async function getRunContents(runId, accessKey, credentials, environment) {
216
+ const response = await apiRequest(`/runs/${runId}/contents`, {
217
+ credentials,
218
+ environment,
219
+ headers: { "X-GuidedTrack-Access-Key": accessKey }
220
+ });
221
+ return response.json();
222
+ }
223
+ function extractErrors(contents) {
224
+ if (!Array.isArray(contents)) {
225
+ if (contents && typeof contents === "object") {
226
+ const errors2 = [];
227
+ for (const value of Object.values(
228
+ contents
229
+ )) {
230
+ if (value && typeof value === "object" && "metadata" in value) {
231
+ const metadata = value.metadata;
232
+ if (metadata?.errors) {
233
+ errors2.push(...metadata.errors);
234
+ }
235
+ }
236
+ }
237
+ return errors2;
238
+ }
239
+ return [];
240
+ }
241
+ const errors = [];
242
+ for (const item of contents) {
243
+ if (item && typeof item === "object" && "metadata" in item) {
244
+ const metadata = item.metadata;
245
+ if (metadata?.errors) {
246
+ errors.push(...metadata.errors);
247
+ }
248
+ }
249
+ }
250
+ return errors;
251
+ }
252
+ async function buildProgram(name, key, credentials, environment) {
253
+ console.log(`>> Building project "${name}" (key: ${key})`);
254
+ const embed = await getEmbedInfo(key, credentials, environment);
255
+ const contents = await getRunContents(
256
+ embed.run_id,
257
+ embed.access_key,
258
+ credentials,
259
+ environment
260
+ );
261
+ const jobId = contents && typeof contents === "object" && !Array.isArray(contents) ? contents.job : null;
262
+ if (jobId && typeof jobId === "number") {
263
+ process.stdout.write(`>>>> Waiting for new build (job: ${jobId})... `);
264
+ await pollJob(jobId, credentials, { environment });
265
+ console.log("done");
266
+ } else {
267
+ console.log(">>>> No changes to build");
268
+ }
269
+ const result = await getRunContents(
270
+ embed.run_id,
271
+ embed.access_key,
272
+ credentials,
273
+ environment
274
+ );
275
+ const errors = extractErrors(result);
276
+ if (errors.length === 0) {
277
+ console.log(">>>> No errors");
278
+ } else {
279
+ console.log(">>>> Found compilation errors:");
280
+ console.log(errors.join("\n"));
281
+ }
282
+ }
283
+
284
+ // src/commands/build.ts
285
+ var PROJECTS_FILENAME = ".gt_projects";
286
+ function registerBuild(program2) {
287
+ program2.command("build").description("Compile programs and report errors").action(async () => {
288
+ const credentials = await resolveCredentials();
289
+ const environment = getEnvironment();
290
+ const projects = await loadProjects();
291
+ if (!projects) {
292
+ console.log(`No ${PROJECTS_FILENAME} file, nothing to build`);
293
+ return;
294
+ }
295
+ for (const project of projects) {
296
+ const found = await findProgramByTitle(project, credentials, environment);
297
+ if (!found) {
298
+ console.error(`>> Program "${project}" not found, skipping...`);
299
+ continue;
300
+ }
301
+ await buildProgram(project, found.key, credentials, environment);
302
+ }
303
+ });
304
+ }
305
+ async function loadProjects() {
306
+ try {
307
+ const raw = await readFile2(
308
+ resolve2(process.cwd(), PROJECTS_FILENAME),
309
+ "utf-8"
310
+ );
311
+ return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ // src/commands/compare.ts
318
+ import { execFileSync } from "child_process";
319
+ import { dirname, resolve as resolve3 } from "path";
320
+ function registerCompare(program2) {
321
+ program2.command("compare").description("Compare programs using gt-compare").allowUnknownOption().argument("[args...]", "Arguments to pass to gt-compare").action((args) => {
322
+ const gtPath = process.env.GT_COMPARE_PATH ?? findGtCompare();
323
+ if (!gtPath) {
324
+ console.error(
325
+ "Could not find gt-compare. Set GT_COMPARE_PATH to the gt-compare directory."
326
+ );
327
+ process.exit(1);
328
+ }
329
+ try {
330
+ execFileSync("./gt-compare", args, {
331
+ cwd: gtPath,
332
+ stdio: "inherit"
333
+ });
334
+ } catch {
335
+ process.exit(1);
336
+ }
337
+ });
338
+ }
339
+ function findGtCompare() {
340
+ try {
341
+ const gtBin = execFileSync("which", ["gt"], { encoding: "utf-8" }).trim();
342
+ return resolve3(dirname(gtBin), "gt-compare");
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ // src/commands/config.ts
349
+ function registerConfig(program2) {
350
+ program2.command("config").description("Print current project configuration").action(async () => {
351
+ const config = await loadConfig();
352
+ console.log(JSON.stringify(config, null, 2));
353
+ });
354
+ }
355
+
356
+ // src/lib/files.ts
357
+ import { readdir } from "fs/promises";
358
+ import { join, relative } from "path";
359
+ async function getLocalGtFiles(dir) {
360
+ const files = [];
361
+ await scanDir(dir, dir, files);
362
+ return files.sort();
363
+ }
364
+ async function scanDir(root, current, results) {
365
+ const entries = await readdir(current, { withFileTypes: true });
366
+ for (const entry of entries) {
367
+ const fullPath = join(current, entry.name);
368
+ if (entry.isDirectory() && entry.name !== "node_modules") {
369
+ await scanDir(root, fullPath, results);
370
+ } else if (entry.isFile() && entry.name.endsWith(".gt")) {
371
+ results.push(relative(root, fullPath));
372
+ }
373
+ }
374
+ }
375
+
376
+ // src/commands/create.ts
377
+ function registerCreate(program2) {
378
+ program2.command("create").description("Create new programs on the server").argument(
379
+ "[names...]",
380
+ "Program names to create (defaults to all .gt files in cwd)"
381
+ ).action(async (names) => {
382
+ const credentials = await resolveCredentials();
383
+ const environment = getEnvironment();
384
+ const filenames = names.length > 0 ? names : await getLocalGtFiles(process.cwd());
385
+ if (filenames.length === 0) {
386
+ console.error("No .gt files to create.");
387
+ process.exit(1);
388
+ }
389
+ console.log(`Creating programs in ${environment}...`);
390
+ for (const filename of filenames) {
391
+ process.stdout.write(`>> Creating "${filename}"... `);
392
+ try {
393
+ const response = await apiRequest("/programs", {
394
+ method: "POST",
395
+ credentials,
396
+ environment,
397
+ body: { name: filename }
398
+ });
399
+ const data = await response.json();
400
+ if (data.job_id) {
401
+ console.log("done");
402
+ } else {
403
+ console.error("failed");
404
+ console.error(`${JSON.stringify(data)} -- skipping`);
405
+ }
406
+ } catch (e) {
407
+ console.error("failed");
408
+ console.error(e.message);
409
+ process.exit(1);
410
+ }
411
+ }
412
+ });
413
+ }
414
+
415
+ // src/lib/prompt.ts
416
+ import { createInterface as createInterface2 } from "readline";
417
+ var rl = () => createInterface2({ input: process.stdin, output: process.stdout });
418
+ async function ask(question) {
419
+ const iface = rl();
420
+ return new Promise((resolve7) => {
421
+ iface.question(question, (answer) => {
422
+ iface.close();
423
+ resolve7(answer.trim());
424
+ });
425
+ });
426
+ }
427
+ async function confirm(question) {
428
+ const answer = await ask(`${question} (y/N) `);
429
+ return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
430
+ }
431
+ async function choose(question, options) {
432
+ console.log(question);
433
+ for (let i = 0; i < options.length; i++) {
434
+ console.log(` ${i + 1}. ${options[i]}`);
435
+ }
436
+ const answer = await ask("Enter choice: ");
437
+ const choice = parseInt(answer, 10);
438
+ if (isNaN(choice) || choice < 1 || choice > options.length) {
439
+ console.log("Invalid choice.");
440
+ return -1;
441
+ }
442
+ return choice - 1;
443
+ }
444
+
445
+ // src/commands/init.ts
446
+ function registerInit(program2) {
447
+ program2.command("init").description("Create gt.config.json by linking local .gt files to programs").action(async () => {
448
+ const credentials = await resolveCredentials();
449
+ const environment = getEnvironment();
450
+ const existing = await loadConfig();
451
+ const programs = existing.programs ?? {};
452
+ const files = await getLocalGtFiles(process.cwd());
453
+ if (files.length === 0) {
454
+ console.log("No .gt files found.");
455
+ return;
456
+ }
457
+ const linkedFiles = new Set(Object.values(programs).map((p) => p.file));
458
+ for (const file of files) {
459
+ if (linkedFiles.has(file)) {
460
+ console.log(`
461
+ "${file}" is already linked, skipping.`);
462
+ continue;
463
+ }
464
+ console.log(`
465
+ Found "${file}"`);
466
+ const shouldLink = await confirm(
467
+ "Do you want to link it to a program on guidedtrack.com?"
468
+ );
469
+ if (!shouldLink) continue;
470
+ const idType = await choose(
471
+ "Which identifier do you want to use to find the program?",
472
+ [
473
+ `The program's title (e.g., "My Cool Program")`,
474
+ "The program's ID (e.g., 12345)",
475
+ `The program's key (e.g., "abc1234")`
476
+ ]
477
+ );
478
+ if (idType === -1) continue;
479
+ let found = null;
480
+ if (idType === 0) {
481
+ const title = await ask("Enter program title: ");
482
+ if (!title) continue;
483
+ process.stdout.write(`Looking up "${title}" in ${environment}... `);
484
+ found = await findProgramByTitle(title, credentials, environment);
485
+ } else if (idType === 1) {
486
+ const idStr = await ask("Enter program ID: ");
487
+ const id = parseInt(idStr, 10);
488
+ if (isNaN(id)) {
489
+ console.log("Invalid ID.");
490
+ continue;
491
+ }
492
+ process.stdout.write(
493
+ `Looking up program ${id} in ${environment}... `
494
+ );
495
+ found = await getProgram(id, credentials, environment);
496
+ } else {
497
+ const key = await ask("Enter program key: ");
498
+ if (!key) continue;
499
+ process.stdout.write(
500
+ `Looking up program "${key}" in ${environment}... `
501
+ );
502
+ found = await findProgramByKey(key, credentials, environment);
503
+ }
504
+ if (!found) {
505
+ console.log("not found.");
506
+ continue;
507
+ }
508
+ console.log(`found! ("${found.name}")`);
509
+ if (programs[found.key]) {
510
+ console.log(
511
+ `Program "${found.name}" is already linked to "${programs[found.key].file}", skipping.`
512
+ );
513
+ continue;
514
+ }
515
+ programs[found.key] = { file, id: found.id };
516
+ linkedFiles.add(file);
517
+ console.log(`Linked "${file}" \u2192 "${found.name}" (key: ${found.key})`);
518
+ }
519
+ const config = { ...existing, programs };
520
+ await saveConfig(config);
521
+ console.log(`
522
+ Wrote ${CONFIG_FILENAME}`);
523
+ });
524
+ }
525
+
526
+ // src/commands/program.ts
527
+ import { exec } from "child_process";
528
+ import { writeFile as writeFile2 } from "fs/promises";
529
+ import { resolve as resolve4 } from "path";
530
+ import { createInterface as createInterface3 } from "readline";
531
+ function registerProgram(parent) {
532
+ const program2 = parent.command("program").description("Manage programs on the server");
533
+ program2.command("list").description("List all programs").action(async () => {
534
+ const credentials = await resolveCredentials();
535
+ const environment = getEnvironment();
536
+ const programs = await listPrograms(credentials, environment);
537
+ if (programs.length === 0) {
538
+ console.log("No programs found.");
539
+ return;
540
+ }
541
+ for (const p of programs) {
542
+ console.log(`${p.id} ${p.key} ${p.name}`);
543
+ }
544
+ });
545
+ program2.command("get").description("Fetch program metadata").argument("<name>", "Program name").action(async (name) => {
546
+ const credentials = await resolveCredentials();
547
+ const environment = getEnvironment();
548
+ const found = await findProgramByTitle(name, credentials, environment);
549
+ if (!found) {
550
+ console.error(`Program "${name}" not found.`);
551
+ process.exit(1);
552
+ }
553
+ console.log(JSON.stringify(found, null, 2));
554
+ });
555
+ program2.command("source").description("Fetch program source code").argument("<name>", "Program name").action(async (name) => {
556
+ const credentials = await resolveCredentials();
557
+ const environment = getEnvironment();
558
+ const found = await findProgramByTitle(name, credentials, environment);
559
+ if (!found) {
560
+ console.error(`Program "${name}" not found.`);
561
+ process.exit(1);
562
+ }
563
+ const source = await fetchProgramSource(
564
+ found.id,
565
+ credentials,
566
+ environment
567
+ );
568
+ process.stdout.write(source);
569
+ });
570
+ program2.command("find").description("Search programs by name").argument("<query>", "Search query").action(async (query) => {
571
+ const credentials = await resolveCredentials();
572
+ const environment = getEnvironment();
573
+ const encoded = encodeURIComponent(query);
574
+ const response = await apiRequest(`/programs.json?query=${encoded}`, {
575
+ credentials,
576
+ environment
577
+ });
578
+ const programs = await response.json();
579
+ if (programs.length === 0) {
580
+ console.log("No programs found.");
581
+ return;
582
+ }
583
+ for (const p of programs) {
584
+ console.log(`${p.id} ${p.key} ${p.name}`);
585
+ }
586
+ });
587
+ program2.command("delete").description("Delete a program (with confirmation)").argument("<name>", "Program name").option("-y, --yes", "Skip confirmation prompt").action(async (name, options) => {
588
+ const credentials = await resolveCredentials();
589
+ const environment = getEnvironment();
590
+ const found = await findProgramByTitle(name, credentials, environment);
591
+ if (!found) {
592
+ console.error(`Program "${name}" not found.`);
593
+ process.exit(1);
594
+ }
595
+ if (!options.yes) {
596
+ const confirmed = await confirm2(
597
+ `Delete "${name}" (id: ${found.id})? This cannot be undone. [y/N] `
598
+ );
599
+ if (!confirmed) {
600
+ console.log("Aborted.");
601
+ return;
602
+ }
603
+ }
604
+ await apiRequest(`/programs/${found.id}.json`, {
605
+ method: "DELETE",
606
+ credentials,
607
+ environment
608
+ });
609
+ console.log(`Deleted "${name}" (id: ${found.id})`);
610
+ });
611
+ program2.command("build").description("Build a specific program").argument("<name>", "Program name").action(async (name) => {
612
+ const credentials = await resolveCredentials();
613
+ const environment = getEnvironment();
614
+ const found = await findProgramByTitle(name, credentials, environment);
615
+ if (!found) {
616
+ console.error(`Program "${name}" not found.`);
617
+ process.exit(1);
618
+ }
619
+ console.log(`Building "${name}" (key: ${found.key})...`);
620
+ const embed = await getEmbedInfo(found.key, credentials, environment);
621
+ const contents = await getRunContents(
622
+ embed.run_id,
623
+ embed.access_key,
624
+ credentials,
625
+ environment
626
+ );
627
+ const jobId = contents && typeof contents === "object" && !Array.isArray(contents) ? contents.job : null;
628
+ if (jobId && typeof jobId === "number") {
629
+ process.stdout.write(`Waiting for build (job: ${jobId})... `);
630
+ await pollJob(jobId, credentials, { environment });
631
+ console.log("done");
632
+ } else {
633
+ console.log("No changes to build");
634
+ }
635
+ const result = await getRunContents(
636
+ embed.run_id,
637
+ embed.access_key,
638
+ credentials,
639
+ environment
640
+ );
641
+ const errors = extractErrors(result);
642
+ if (errors.length === 0) {
643
+ console.log("No errors");
644
+ } else {
645
+ console.log("Found compilation errors:");
646
+ console.log(errors.join("\n"));
647
+ }
648
+ });
649
+ program2.command("view").description("Open program edit page in browser").argument("<name>", "Program name").action(async (name) => {
650
+ const credentials = await resolveCredentials();
651
+ const environment = getEnvironment();
652
+ const found = await findProgramByTitle(name, credentials, environment);
653
+ if (!found) {
654
+ console.error(`Program "${name}" not found.`);
655
+ process.exit(1);
656
+ }
657
+ const url = `${ENVIRONMENT_HOSTS[environment]}/programs/${found.id}/edit`;
658
+ openBrowser(url);
659
+ });
660
+ program2.command("preview").description("Open program preview in browser").argument("<name>", "Program name").action(async (name) => {
661
+ const credentials = await resolveCredentials();
662
+ const environment = getEnvironment();
663
+ const found = await findProgramByTitle(name, credentials, environment);
664
+ if (!found) {
665
+ console.error(`Program "${name}" not found.`);
666
+ process.exit(1);
667
+ }
668
+ const url = `${ENVIRONMENT_HOSTS[environment]}/programs/${found.key}/preview`;
669
+ openBrowser(url);
670
+ });
671
+ program2.command("run").description("Open program run page in browser").argument("<name>", "Program name").action(async (name) => {
672
+ const credentials = await resolveCredentials();
673
+ const environment = getEnvironment();
674
+ const found = await findProgramByTitle(name, credentials, environment);
675
+ if (!found) {
676
+ console.error(`Program "${name}" not found.`);
677
+ process.exit(1);
678
+ }
679
+ const url = `${ENVIRONMENT_HOSTS[environment]}/programs/${found.key}/run`;
680
+ openBrowser(url);
681
+ });
682
+ program2.command("data").alias("csv").description("Download program data as CSV").argument("<name>", "Program name").option("-o, --output <file>", "Save to file instead of stdout").action(async (name, options) => {
683
+ const credentials = await resolveCredentials();
684
+ const environment = getEnvironment();
685
+ const found = await findProgramByTitle(name, credentials, environment);
686
+ if (!found) {
687
+ console.error(`Program "${name}" not found.`);
688
+ process.exit(1);
689
+ }
690
+ const response = await apiRequest(`/programs/${found.id}/csv`, {
691
+ credentials,
692
+ environment
693
+ });
694
+ const csv = await response.text();
695
+ if (options.output) {
696
+ await writeFile2(resolve4(process.cwd(), options.output), csv);
697
+ console.log(`Saved to ${options.output}`);
698
+ } else {
699
+ process.stdout.write(csv);
700
+ }
701
+ });
702
+ }
703
+ function confirm2(prompt2) {
704
+ const rl2 = createInterface3({ input: process.stdin, output: process.stdout });
705
+ return new Promise((resolve7) => {
706
+ rl2.question(prompt2, (answer) => {
707
+ rl2.close();
708
+ resolve7(answer.toLowerCase() === "y");
709
+ });
710
+ });
711
+ }
712
+ function openBrowser(url) {
713
+ console.log(url);
714
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
715
+ exec(`${cmd} ${JSON.stringify(url)}`);
716
+ }
717
+
718
+ // src/commands/pull.ts
719
+ import { writeFile as writeFile3 } from "fs/promises";
720
+ import { resolve as resolve5 } from "path";
721
+ function registerPull(program2) {
722
+ program2.command("pull").description("Download program source from the server").option("-o, --only <key>", "Pull only the specified program (by key)").action(async (options) => {
723
+ const credentials = await resolveCredentials();
724
+ const environment = getEnvironment();
725
+ const config = await loadConfig();
726
+ const programs = config.programs ?? {};
727
+ let entries;
728
+ if (options.only) {
729
+ const ref = programs[options.only];
730
+ if (!ref) {
731
+ console.error(
732
+ `Program with key "${options.only}" not found in config.`
733
+ );
734
+ process.exit(1);
735
+ }
736
+ entries = [[options.only, ref]];
737
+ } else {
738
+ entries = Object.entries(programs);
739
+ }
740
+ if (entries.length === 0) {
741
+ console.error("No programs in config. Run `gt init` first.");
742
+ process.exit(1);
743
+ }
744
+ console.log(`Pulling from ${environment}...`);
745
+ for (const [, ref] of entries) {
746
+ process.stdout.write(
747
+ `>> Downloading "${ref.file}" (id: ${ref.id})... `
748
+ );
749
+ const source = await fetchProgramSource(
750
+ ref.id,
751
+ credentials,
752
+ environment
753
+ );
754
+ await writeFile3(resolve5(process.cwd(), ref.file), source);
755
+ console.log("done");
756
+ }
757
+ });
758
+ }
759
+
760
+ // src/commands/push.ts
761
+ import { readFile as readFile3 } from "fs/promises";
762
+ import { resolve as resolve6 } from "path";
763
+ function registerPush(program2) {
764
+ program2.command("push").description("Upload local program files to the server").option("-o, --only <key>", "Push only the specified program (by key)").option("-b, --build", "Build after pushing").action(async (options) => {
765
+ const credentials = await resolveCredentials();
766
+ const environment = getEnvironment();
767
+ const config = await loadConfig();
768
+ const programs = config.programs ?? {};
769
+ let entries;
770
+ if (options.only) {
771
+ const ref = programs[options.only];
772
+ if (!ref) {
773
+ console.error(
774
+ `Program with key "${options.only}" not found in config.`
775
+ );
776
+ process.exit(1);
777
+ }
778
+ entries = [[options.only, ref]];
779
+ } else {
780
+ entries = Object.entries(programs);
781
+ }
782
+ if (entries.length === 0) {
783
+ console.error("No programs in config. Run `gt init` first.");
784
+ process.exit(1);
785
+ }
786
+ console.log(`Pushing to ${environment}...`);
787
+ const pushed = [];
788
+ for (const [key, ref] of entries) {
789
+ process.stdout.write(
790
+ `>> Updating "${ref.file}" (id: ${ref.id})... `
791
+ );
792
+ const contents = await readFile3(
793
+ resolve6(process.cwd(), ref.file),
794
+ "utf-8"
795
+ );
796
+ await apiRequest(`/programs/${ref.id}.json`, {
797
+ method: "PUT",
798
+ credentials,
799
+ environment,
800
+ body: { contents, program: { description: "" } }
801
+ });
802
+ console.log("done");
803
+ pushed.push({ file: ref.file, key });
804
+ }
805
+ if (options.build && pushed.length > 0) {
806
+ console.log("\nBuilding pushed programs...");
807
+ for (const { file, key } of pushed) {
808
+ await buildProgram(file, key, credentials, environment);
809
+ }
810
+ }
811
+ });
812
+ }
813
+
814
+ // src/commands/request.ts
815
+ function registerRequest(program2) {
816
+ program2.command("request").description("Send a generic API request").argument("<path>", "API path (e.g., /programs.json)").option("-X, --method <method>", "HTTP method", "GET").option("-d, --data <json>", "Request body (JSON string)").option(
817
+ "-H, --header <header...>",
818
+ "Additional headers (key:value)"
819
+ ).action(
820
+ async (path, options) => {
821
+ const credentials = await resolveCredentials();
822
+ const environment = getEnvironment();
823
+ const headers = {};
824
+ if (options.header) {
825
+ for (const h of options.header) {
826
+ const idx = h.indexOf(":");
827
+ if (idx === -1) {
828
+ console.error(`Invalid header format: "${h}" (expected key:value)`);
829
+ process.exit(1);
830
+ }
831
+ headers[h.slice(0, idx).trim()] = h.slice(idx + 1).trim();
832
+ }
833
+ }
834
+ let body;
835
+ if (options.data) {
836
+ try {
837
+ body = JSON.parse(options.data);
838
+ } catch {
839
+ console.error("Invalid JSON body");
840
+ process.exit(1);
841
+ }
842
+ }
843
+ const response = await apiRequest(path, {
844
+ method: options.method,
845
+ credentials,
846
+ environment,
847
+ headers: Object.keys(headers).length > 0 ? headers : void 0,
848
+ body
849
+ });
850
+ const text = await response.text();
851
+ try {
852
+ const json = JSON.parse(text);
853
+ console.log(JSON.stringify(json, null, 2));
854
+ } catch {
855
+ console.log(text);
856
+ }
857
+ }
858
+ );
859
+ }
860
+
861
+ // src/index.ts
862
+ var program = new Command();
863
+ program.name("gt").description("CLI for the GuidedTrack web API").version("0.1.0");
864
+ registerPush(program);
865
+ registerPull(program);
866
+ registerCreate(program);
867
+ registerBuild(program);
868
+ registerCompare(program);
869
+ registerInit(program);
870
+ registerConfig(program);
871
+ registerProgram(program);
872
+ registerRequest(program);
873
+ program.parseAsync().catch((err) => {
874
+ console.error(err.message);
875
+ process.exit(1);
876
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "bin": {
3
+ "gt": "dist/index.js"
4
+ },
5
+ "dependencies": {
6
+ "commander": "^13.1.0"
7
+ },
8
+ "description": "CLI for the GuidedTrack web API",
9
+ "devDependencies": {
10
+ "@eslint/css": "^0.14.1",
11
+ "@eslint/js": "^9.12.0",
12
+ "@eslint/json": "^0.14.0",
13
+ "@eslint/markdown": "^7.5.1",
14
+ "@types/node": "^22.15.0",
15
+ "eslint": "^9.39.1",
16
+ "eslint-plugin-html": "^7.1.0",
17
+ "globals": "^15.11.0",
18
+ "prettier": "^3.7.1",
19
+ "tsup": "^8.4.0",
20
+ "tsx": "^4.19.0",
21
+ "typescript": "^5.8.0",
22
+ "typescript-eslint": "^8.31.0",
23
+ "vitest": "^3.1.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "name": "@jrc03c/gt-cli",
32
+ "scripts": {
33
+ "build": "tsup && chmod +x dist/index.js",
34
+ "dev": "tsx src/index.ts",
35
+ "format": "prettier --write .",
36
+ "lint": "eslint .",
37
+ "pub": "npm version patch --force && npm publish --access=public",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest"
40
+ },
41
+ "type": "module",
42
+ "version": "0.1.1"
43
+ }