@pd4castr/cli 0.0.5 → 0.0.6

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 (2) hide show
  1. package/dist/index.js +754 -190
  2. package/package.json +9 -3
package/dist/index.js CHANGED
@@ -51,22 +51,30 @@ var AUTH0_CLIENT_ID = "Q5tQNF57cQlVXnVsqnU0hhgy92rVb03W";
51
51
  var AUTH0_AUDIENCE = "https://api.pd4castr.com.au";
52
52
  var GLOBAL_CONFIG_FILE = ".pd4castr";
53
53
  var PROJECT_CONFIG_FILE = ".pd4castrrc.json";
54
- var API_URL = "https://pd4castr-api.pipelabs.app";
54
+ var DEFAULT_API_URL = "https://pd4castr-api.pipelabs.app";
55
+ var DEFAULT_INPUT_SOURCE_ID = "0bdfd52b-efaa-455e-9a3b-1a6d2b879b73";
55
56
  var TEST_INPUT_DATA_DIR = "test_input";
56
57
  var TEST_OUTPUT_DATA_DIR = "test_output";
58
+ var TEST_WEBSERVER_PORT = 9800;
57
59
  var TEST_OUTPUT_FILENAME = `output-${Date.now()}.json`;
60
+ var DOCKER_HOSTNAME_DEFAULT = "host.docker.internal";
61
+ var DOCKER_HOSTNAME_WSL = "wsl.host";
62
+ var WSL_NETWORK_INTERFACE_DEFAULT = "eth0";
58
63
 
59
64
  // src/commands/fetch/handle-action.ts
60
65
  import path5 from "path";
66
+ import { HTTPError } from "ky";
61
67
  import ora from "ora";
62
- import { ZodError } from "zod";
68
+ import { ZodError as ZodError2 } from "zod";
63
69
 
64
- // src/config/load-project-config.ts
70
+ // src/config/load-project-context.ts
71
+ import fs2 from "fs/promises";
65
72
  import path from "path";
66
- import { lilconfig } from "lilconfig";
73
+ import { ZodError } from "zod";
67
74
 
68
75
  // src/schemas/project-config-schema.ts
69
76
  import { z } from "zod";
77
+ var fileFormatSchema = z.enum(["csv", "json", "parquet"]);
70
78
  var aemoDataFetcherSchema = z.object({
71
79
  type: z.literal("AEMO_MMS"),
72
80
  checkInterval: z.number().int().min(60),
@@ -78,49 +86,70 @@ var aemoDataFetcherSchema = z.object({
78
86
  var dataFetcherSchema = z.discriminatedUnion("type", [aemoDataFetcherSchema]);
79
87
  var modelInputSchema = z.object({
80
88
  key: z.string(),
81
- inputSource: z.string().optional(),
89
+ inputSource: z.string().optional().default(DEFAULT_INPUT_SOURCE_ID),
82
90
  trigger: z.enum(["WAIT_FOR_LATEST_FILE", "USE_MOST_RECENT_FILE"]),
83
- fetcher: dataFetcherSchema.optional()
91
+ uploadFileFormat: fileFormatSchema.optional().default("json"),
92
+ targetFileFormat: fileFormatSchema.optional().default("json"),
93
+ fetcher: dataFetcherSchema.optional().nullable()
84
94
  });
85
95
  var modelOutputSchema = z.object({
86
96
  name: z.string(),
97
+ type: z.enum(["float", "integer", "string", "date", "boolean", "unknown"]),
87
98
  seriesKey: z.boolean(),
88
99
  colour: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional()
89
100
  });
101
+ var CONFIG_WARNING_KEY = "// WARNING: DO NOT MODIFY THESE SYSTEM MANAGED VALUES";
90
102
  var projectConfigSchema = z.object({
91
- $$id: z.string().nullable(),
92
- $$modelGroupID: z.string().nullable(),
93
- $$revision: z.number().int().min(0).default(0),
94
- $$dockerImage: z.string().nullable(),
95
103
  name: z.string(),
104
+ forecastVariable: z.enum(["price"]),
105
+ timeHorizon: z.enum(["actual", "day_ahead", "week_ahead", "quarterly"]),
96
106
  metadata: z.record(z.string(), z.any()).optional(),
97
- forecastVariable: z.enum(["PRICE"]),
98
- timeHorizon: z.enum(["ACTUAL", "DAY_AHEAD", "WEEK_AHEAD"]),
99
107
  inputs: z.array(modelInputSchema),
100
- outputs: z.array(modelOutputSchema)
108
+ outputs: z.array(modelOutputSchema),
109
+ [CONFIG_WARNING_KEY]: z.string().optional().default(""),
110
+ $$id: z.string().nullable().optional().default(null),
111
+ $$modelGroupID: z.string().nullable().optional().default(null),
112
+ $$revision: z.number().int().min(0).nullable().optional().default(null),
113
+ $$dockerImage: z.string().nullable().optional().default(null)
101
114
  });
102
115
 
103
- // src/config/parse-project-config.ts
104
- function parseProjectConfig(config) {
105
- return projectConfigSchema.parse(config);
116
+ // src/utils/is-existing-path.ts
117
+ import fs from "fs/promises";
118
+ async function isExistingPath(path15) {
119
+ try {
120
+ await fs.access(path15);
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
106
125
  }
107
126
 
108
- // src/config/load-project-config.ts
109
- async function loadProjectConfig() {
110
- const result = await lilconfig("pd4castr", {
111
- searchPlaces: [PROJECT_CONFIG_FILE]
112
- }).search();
113
- if (!result?.config) {
127
+ // src/config/load-project-context.ts
128
+ async function loadProjectContext() {
129
+ const projectRoot = process.cwd();
130
+ const configPath = path.join(projectRoot, PROJECT_CONFIG_FILE);
131
+ const configExists = await isExistingPath(configPath);
132
+ if (!configExists) {
114
133
  throw new Error(
115
134
  "No config found (docs: https://github.com/pipelabs/pd4castr-model-examples/blob/main/docs/005-config.md)."
116
135
  );
117
136
  }
118
- const config = parseProjectConfig(result.config);
119
- const projectRoot = path.dirname(result.filepath);
120
- return {
121
- config,
122
- projectRoot
123
- };
137
+ try {
138
+ const configFileContents = await fs2.readFile(configPath, "utf8");
139
+ const rawConfig = JSON.parse(configFileContents);
140
+ const config = projectConfigSchema.parse(rawConfig);
141
+ return {
142
+ config,
143
+ projectRoot
144
+ };
145
+ } catch (error) {
146
+ if (error instanceof ZodError) {
147
+ throw error;
148
+ }
149
+ throw new Error(
150
+ "Failed to parse project config (docs: https://github.com/pipelabs/pd4castr-model-examples/blob/main/docs/005-config.md)."
151
+ );
152
+ }
124
153
  }
125
154
 
126
155
  // src/utils/create-link.ts
@@ -133,54 +162,54 @@ function createLink(text, url) {
133
162
  return `${start}${text}${end}`;
134
163
  }
135
164
 
165
+ // src/utils/format-nest-error-message.ts
166
+ function formatNestErrorMessage(error) {
167
+ return `[${error.error?.toUpperCase() ?? "UNKNOWN"}] ${error.message}`;
168
+ }
169
+
136
170
  // src/utils/get-auth.ts
137
171
  import invariant from "tiny-invariant";
138
172
 
139
- // src/commands/login/utils/is-authed.ts
140
- function isAuthed(config) {
141
- const isTokenExpired = config.accessTokenExpiresAt && config.accessTokenExpiresAt <= Date.now();
142
- return Boolean(config.accessToken) && !isTokenExpired;
143
- }
144
-
145
173
  // src/config/load-global-config.ts
146
- import fs2 from "fs/promises";
174
+ import fs3 from "fs/promises";
147
175
  import os from "os";
148
176
  import path2 from "path";
149
177
 
150
178
  // src/schemas/global-config-schema.ts
151
179
  import { z as z2 } from "zod";
152
180
  var globalConfigSchema = z2.object({
153
- accessToken: z2.string().optional(),
154
- accessTokenExpiresAt: z2.number().optional()
181
+ accessToken: z2.string().nullable(),
182
+ accessTokenExpiresAt: z2.number().nullable()
155
183
  });
156
184
 
157
- // src/utils/is-existing-path.ts
158
- import fs from "fs/promises";
159
- async function isExistingPath(path12) {
160
- try {
161
- await fs.access(path12);
162
- return true;
163
- } catch {
164
- return false;
165
- }
166
- }
167
-
168
185
  // src/config/load-global-config.ts
169
186
  async function loadGlobalConfig() {
170
187
  const configPath = path2.join(os.homedir(), GLOBAL_CONFIG_FILE);
171
188
  const configExists = await isExistingPath(configPath);
172
189
  if (!configExists) {
173
- await fs2.writeFile(configPath, JSON.stringify({}));
174
- return {};
190
+ await fs3.writeFile(configPath, JSON.stringify({}));
191
+ return getDefaultConfig();
175
192
  }
176
193
  try {
177
- const configFileContents = await fs2.readFile(configPath, "utf8");
194
+ const configFileContents = await fs3.readFile(configPath, "utf8");
178
195
  const config = JSON.parse(configFileContents);
179
196
  return globalConfigSchema.parse(config);
180
197
  } catch {
181
- return {};
198
+ return getDefaultConfig();
182
199
  }
183
200
  }
201
+ function getDefaultConfig() {
202
+ return {
203
+ accessToken: null,
204
+ accessTokenExpiresAt: null
205
+ };
206
+ }
207
+
208
+ // src/utils/is-authed.ts
209
+ function isAuthed(config) {
210
+ const isTokenExpired = config.accessTokenExpiresAt && config.accessTokenExpiresAt <= Date.now();
211
+ return Boolean(config.accessToken) && !isTokenExpired;
212
+ }
184
213
 
185
214
  // src/utils/get-auth.ts
186
215
  async function getAuth() {
@@ -204,25 +233,49 @@ function logZodIssues(error) {
204
233
  }
205
234
 
206
235
  // src/commands/fetch/utils/fetch-aemo-data.ts
207
- import fs3 from "fs/promises";
236
+ import fs4 from "fs/promises";
208
237
  import path3 from "path";
209
238
 
210
- // src/api.ts
239
+ // src/api/api.ts
211
240
  import ky from "ky";
241
+
242
+ // src/schemas/env-schema.ts
243
+ import { z as z3 } from "zod";
244
+ var envSchema = z3.object({
245
+ // wsl sets this environment variable on all distros that i've checked
246
+ isWSL: z3.boolean().optional().default(() => Boolean(process.env.WSL_DISTRO_NAME)),
247
+ apiURL: z3.string().optional().default(() => process.env.PD4CASTR_API_URL ?? DEFAULT_API_URL),
248
+ wslNetworkInterface: z3.string().optional().default(
249
+ () => process.env.PD4CASTR_WSL_NETWORK_INTERFACE ?? WSL_NETWORK_INTERFACE_DEFAULT
250
+ )
251
+ });
252
+
253
+ // src/utils/get-env.ts
254
+ function getEnv() {
255
+ return envSchema.parse(process.env);
256
+ }
257
+
258
+ // src/api/api.ts
212
259
  var api = ky.create({
213
- prefixUrl: process.env.PD4CASTR_API_URL ?? API_URL
260
+ prefixUrl: getEnv().apiURL
214
261
  });
215
262
 
263
+ // src/api/query-data-fetcher.ts
264
+ async function queryDataFetcher(querySQL, authCtx) {
265
+ const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
266
+ const payload2 = { query: querySQL, type: "AEMO_MMS" };
267
+ const result = await api.post("data-fetcher/query", { json: payload2, headers }).json();
268
+ return result;
269
+ }
270
+
216
271
  // src/commands/fetch/utils/fetch-aemo-data.ts
217
272
  async function fetchAEMOData(dataFetcher, authCtx, ctx) {
218
273
  const queryPath = path3.resolve(
219
274
  ctx.projectRoot,
220
275
  dataFetcher.config.fetchQuery
221
276
  );
222
- const querySQL = await fs3.readFile(queryPath, "utf8");
223
- const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
224
- const payload2 = { query: querySQL, type: "AEMO_MMS" };
225
- const result = await api.post("data-fetcher/query", { json: payload2, headers }).json();
277
+ const querySQL = await fs4.readFile(queryPath, "utf8");
278
+ const result = await queryDataFetcher(querySQL, authCtx);
226
279
  return result;
227
280
  }
228
281
 
@@ -239,21 +292,21 @@ function getFetcher(type) {
239
292
  }
240
293
 
241
294
  // src/commands/fetch/utils/write-test-data.ts
242
- import fs4 from "fs/promises";
295
+ import fs5 from "fs/promises";
243
296
  import path4 from "path";
244
297
 
245
298
  // src/utils/get-input-filename.ts
246
299
  function getInputFilename(modelInput) {
247
- return `${modelInput.key}.json`;
300
+ return `${modelInput.key}.${modelInput.targetFileFormat}`;
248
301
  }
249
302
 
250
303
  // src/commands/fetch/utils/write-test-data.ts
251
304
  async function writeTestData(inputData, modelInput, inputDataDir, ctx) {
252
305
  const inputDir = path4.resolve(ctx.projectRoot, inputDataDir);
253
- await fs4.mkdir(inputDir, { recursive: true });
306
+ await fs5.mkdir(inputDir, { recursive: true });
254
307
  const inputFilename = getInputFilename(modelInput);
255
308
  const inputPath = path4.resolve(inputDir, inputFilename);
256
- await fs4.writeFile(inputPath, JSON.stringify(inputData, void 0, 2));
309
+ await fs5.writeFile(inputPath, JSON.stringify(inputData, void 0, 2));
257
310
  }
258
311
 
259
312
  // src/commands/fetch/handle-action.ts
@@ -262,7 +315,7 @@ async function handleAction(options) {
262
315
  const spinner = ora("Starting data fetch...").start();
263
316
  try {
264
317
  const authCtx = await getAuth();
265
- const ctx = await loadProjectConfig();
318
+ const ctx = await loadProjectContext();
266
319
  for (const input2 of ctx.config.inputs) {
267
320
  if (!input2.fetcher) {
268
321
  spinner.warn(`\`${input2.key}\` - no data fetcher defined, skipping`);
@@ -286,11 +339,17 @@ async function handleAction(options) {
286
339
  ${link} to view fetched data
287
340
  `);
288
341
  } catch (error) {
289
- if (error instanceof ZodError) {
342
+ if (error instanceof ZodError2) {
290
343
  spinner.fail("Config validation failed");
291
344
  logZodIssues(error);
345
+ } else if (error instanceof HTTPError) {
346
+ const errorResponse = await error.response.json();
347
+ spinner.fail(formatNestErrorMessage(errorResponse));
292
348
  } else if (error instanceof Error) {
293
349
  spinner.fail(error.message);
350
+ if (error.cause) {
351
+ console.error(error.cause);
352
+ }
294
353
  }
295
354
  process.exit(1);
296
355
  }
@@ -359,8 +418,8 @@ async function handleAction2() {
359
418
  spinner.succeed("Template fetched successfully");
360
419
  } catch (error) {
361
420
  spinner.fail("Error fetching template");
362
- if (error instanceof Error) {
363
- console.error(error.message);
421
+ if (error instanceof Error && error.cause) {
422
+ console.error(error.cause);
364
423
  }
365
424
  process.exit(1);
366
425
  }
@@ -382,19 +441,22 @@ function registerInitCommand(program2) {
382
441
 
383
442
  // src/commands/login/handle-action.ts
384
443
  import ora3 from "ora";
385
- import { ZodError as ZodError2 } from "zod";
444
+ import { ZodError as ZodError3 } from "zod";
386
445
 
387
446
  // src/config/update-global-config.ts
388
- import fs5 from "fs/promises";
447
+ import fs6 from "fs/promises";
389
448
  import os2 from "os";
390
449
  import path7 from "path";
391
- async function updateGlobalConfig(updatedConfig) {
450
+ import { produce } from "immer";
451
+ async function updateGlobalConfig(updateFn) {
452
+ const globalConfig = await loadGlobalConfig();
453
+ const updatedConfig = produce(globalConfig, updateFn);
392
454
  const configPath = path7.join(os2.homedir(), GLOBAL_CONFIG_FILE);
393
- await fs5.writeFile(configPath, JSON.stringify(updatedConfig, void 0, 2));
455
+ await fs6.writeFile(configPath, JSON.stringify(updatedConfig, void 0, 2));
394
456
  }
395
457
 
396
458
  // src/commands/login/utils/complete-auth-flow.ts
397
- import { HTTPError } from "ky";
459
+ import { HTTPError as HTTPError2 } from "ky";
398
460
 
399
461
  // src/commands/login/auth0-api.ts
400
462
  import ky2 from "ky";
@@ -417,7 +479,7 @@ async function completeAuthFlow(authCtx) {
417
479
  };
418
480
  return authPayload;
419
481
  } catch (error) {
420
- if (!(error instanceof HTTPError)) {
482
+ if (!(error instanceof HTTPError2)) {
421
483
  throw error;
422
484
  }
423
485
  const errorResponse = await error.response.json();
@@ -472,19 +534,20 @@ async function handleAction3() {
472
534
  `);
473
535
  spinner.start("Waiting for login to complete...");
474
536
  const authPayload = await completeAuthFlow(authCtx);
475
- const updatedGlobalConfig = {
476
- ...globalConfig,
477
- accessToken: authPayload.accessToken,
478
- accessTokenExpiresAt: authPayload.expiresAt
479
- };
480
- await updateGlobalConfig(updatedGlobalConfig);
537
+ await updateGlobalConfig((config) => {
538
+ config.accessToken = authPayload.accessToken;
539
+ config.accessTokenExpiresAt = authPayload.expiresAt;
540
+ });
481
541
  spinner.succeed("Successfully logged in to the pd4castr API");
482
542
  } catch (error) {
483
- if (error instanceof ZodError2) {
543
+ if (error instanceof ZodError3) {
484
544
  spinner.fail("Config validation failed");
485
545
  logZodIssues(error);
486
546
  } else if (error instanceof Error) {
487
547
  spinner.fail(error.message);
548
+ if (error.cause) {
549
+ console.error(error.cause);
550
+ }
488
551
  }
489
552
  process.exit(1);
490
553
  }
@@ -495,15 +558,92 @@ function registerLoginCommand(program2) {
495
558
  program2.command("login").description("Logs in to the pd4castr API.").action(handleAction3);
496
559
  }
497
560
 
498
- // src/commands/test/handle-action.ts
499
- import path11 from "path";
500
- import { ExecaError } from "execa";
501
- import express from "express";
561
+ // src/commands/logout/handle-action.ts
502
562
  import ora4 from "ora";
503
- import slugify from "slugify";
504
- import { ZodError as ZodError3 } from "zod";
563
+ import { ZodError as ZodError4 } from "zod";
564
+ async function handleAction4() {
565
+ const spinner = ora4("Logging out of the pd4castr API...").start();
566
+ try {
567
+ const globalConfig = await loadGlobalConfig();
568
+ if (!isAuthed(globalConfig)) {
569
+ spinner.succeed("Already logged out!");
570
+ return;
571
+ }
572
+ await updateGlobalConfig((config) => {
573
+ config.accessToken = null;
574
+ config.accessTokenExpiresAt = null;
575
+ });
576
+ spinner.succeed("Successfully logged out of the pd4castr API");
577
+ } catch (error) {
578
+ if (error instanceof ZodError4) {
579
+ spinner.fail("Config validation failed");
580
+ logZodIssues(error);
581
+ } else if (error instanceof Error) {
582
+ spinner.fail(error.message);
583
+ if (error.cause) {
584
+ console.error(error.cause);
585
+ }
586
+ }
587
+ process.exit(1);
588
+ }
589
+ }
590
+
591
+ // src/commands/logout/index.ts
592
+ function registerLogoutCommand(program2) {
593
+ program2.command("logout").description("Logs out of the pd4castr API.").action(handleAction4);
594
+ }
505
595
 
506
- // src/commands/test/utils/build-docker-image.ts
596
+ // src/commands/publish/handle-action.ts
597
+ import express2 from "express";
598
+ import { HTTPError as HTTPError3 } from "ky";
599
+ import ora5 from "ora";
600
+ import { ZodError as ZodError5 } from "zod";
601
+
602
+ // src/utils/start-web-server.ts
603
+ async function startWebServer(app, port) {
604
+ return new Promise((resolve) => {
605
+ const server = app.listen(port, () => {
606
+ resolve({
607
+ server,
608
+ [Symbol.dispose]: () => server.close()
609
+ });
610
+ });
611
+ });
612
+ }
613
+
614
+ // src/commands/publish/handle-create-model-flow.ts
615
+ import * as inquirer2 from "@inquirer/prompts";
616
+ import chalk2 from "chalk";
617
+
618
+ // src/api/create-model.ts
619
+ async function createModel(config, authCtx) {
620
+ const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
621
+ const result = await api.post("model", { headers, json: config }).json();
622
+ return result;
623
+ }
624
+
625
+ // src/api/get-registry-push-credentials.ts
626
+ async function getRegistryPushCredentials(modelID, authCtx) {
627
+ const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
628
+ const searchParams = new URLSearchParams(`modelId=${modelID}`);
629
+ const result = await api.get("registry/push-credentials", { headers, searchParams }).json();
630
+ return result;
631
+ }
632
+
633
+ // src/config/update-project-config.ts
634
+ import fs7 from "fs/promises";
635
+ import path8 from "path";
636
+ import { produce as produce2 } from "immer";
637
+ async function updateProjectConfig(updateFn) {
638
+ const projectConfig = await loadProjectContext();
639
+ const updatedConfig = produce2(projectConfig.config, updateFn);
640
+ await fs7.writeFile(
641
+ path8.join(projectConfig.projectRoot, PROJECT_CONFIG_FILE),
642
+ JSON.stringify(updatedConfig, void 0, 2)
643
+ );
644
+ }
645
+
646
+ // src/docker/build-docker-image.ts
507
647
  import { execa } from "execa";
508
648
  async function buildDockerImage(dockerImage, ctx) {
509
649
  try {
@@ -516,59 +656,183 @@ async function buildDockerImage(dockerImage, ctx) {
516
656
  }
517
657
  }
518
658
 
519
- // src/commands/test/utils/check-input-files.ts
520
- import path8 from "path";
521
- async function checkInputFiles(inputFiles, inputDataPath, ctx) {
522
- for (const inputFile of inputFiles) {
523
- const filePath = path8.join(ctx.projectRoot, inputDataPath, inputFile);
524
- const exists = await isExistingPath(filePath);
525
- if (!exists) {
526
- throw new Error(
527
- `Input data not found (${inputFile}) - did you need to run \`pd4castr fetch\`?`
528
- );
529
- }
659
+ // src/docker/login-to-docker-registry.ts
660
+ import { execa as execa2 } from "execa";
661
+ async function loginToDockerRegistry(authConfig) {
662
+ try {
663
+ await execa2(
664
+ "docker",
665
+ [
666
+ "login",
667
+ authConfig.registry,
668
+ "--username",
669
+ authConfig.username,
670
+ "--password-stdin"
671
+ ],
672
+ { input: authConfig.password }
673
+ );
674
+ } catch (error) {
675
+ throw new Error("Failed to login to docker registry", { cause: error });
676
+ }
677
+ }
678
+
679
+ // src/docker/push-docker-image.ts
680
+ import { execa as execa3 } from "execa";
681
+ async function pushDockerImage(dockerImage, pushRef) {
682
+ try {
683
+ await execa3("docker", ["tag", dockerImage, pushRef]);
684
+ await execa3("docker", ["push", pushRef]);
685
+ } catch (error) {
686
+ throw new Error("Failed to push docker image", { cause: error });
530
687
  }
531
688
  }
532
689
 
533
- // src/commands/test/utils/create-input-handler.ts
690
+ // src/utils/get-docker-image.ts
691
+ import slugify from "slugify";
692
+ function getDockerImage(ctx) {
693
+ const sluggedName = slugify(ctx.config.name, { lower: true });
694
+ const dockerImage = `pd4castr/${sluggedName}-local:${Date.now()}`;
695
+ return dockerImage;
696
+ }
697
+
698
+ // src/utils/get-model-config-from-project-config.ts
699
+ import fs8 from "fs/promises";
534
700
  import path9 from "path";
535
- function createInputHandler(inputFilesPath, modelIOChecks, ctx) {
536
- return (req, res) => {
537
- if (!modelIOChecks.isValidInput(req.params.filename)) {
538
- return res.status(404).json({ error: "File not found" });
701
+ async function getModelConfigFromProjectConfig(ctx) {
702
+ const inputs = await getInputsWithInlinedSQL(ctx);
703
+ const { $$id, $$modelGroupID, $$revision, $$dockerImage, ...config } = ctx.config;
704
+ return {
705
+ ...config,
706
+ id: $$id,
707
+ modelGroupId: $$modelGroupID,
708
+ revision: $$revision ?? 0,
709
+ dockerImage: $$dockerImage,
710
+ inputs
711
+ };
712
+ }
713
+ var FETCHERS_WITH_SQL = /* @__PURE__ */ new Set(["AEMO_MMS"]);
714
+ async function getInputsWithInlinedSQL(ctx) {
715
+ const inputsWithSQL = [];
716
+ for (const input2 of ctx.config.inputs) {
717
+ if (!input2.fetcher || !FETCHERS_WITH_SQL.has(input2.fetcher.type)) {
718
+ inputsWithSQL.push(input2);
719
+ continue;
539
720
  }
540
- modelIOChecks.trackInputHandled(req.params.filename);
541
- const filePath = path9.join(
721
+ const fetchQueryPath = path9.resolve(
542
722
  ctx.projectRoot,
543
- inputFilesPath,
544
- req.params.filename
723
+ input2.fetcher.config.fetchQuery
545
724
  );
546
- return res.sendFile(filePath);
547
- };
725
+ const checkQueryPath = path9.resolve(
726
+ ctx.projectRoot,
727
+ input2.fetcher.config.checkQuery
728
+ );
729
+ const [fetchQuerySQL, checkQuerySQL] = await Promise.all([
730
+ fs8.readFile(fetchQueryPath, "utf8"),
731
+ fs8.readFile(checkQueryPath, "utf8")
732
+ ]);
733
+ const inputWithSQL = {
734
+ ...input2,
735
+ fetcher: {
736
+ ...input2.fetcher,
737
+ config: {
738
+ ...input2.fetcher.config,
739
+ fetchQuery: fetchQuerySQL,
740
+ checkQuery: checkQuerySQL
741
+ }
742
+ }
743
+ };
744
+ inputsWithSQL.push(inputWithSQL);
745
+ }
746
+ return inputsWithSQL;
548
747
  }
549
748
 
550
- // src/commands/test/utils/create-output-handler.ts
551
- import fs6 from "fs/promises";
552
- import path10 from "path";
553
- function createOutputHandler(modelIOChecks, ctx) {
554
- return async (req, res) => {
555
- modelIOChecks.trackOutputHandled();
556
- const outputPath = path10.join(ctx.projectRoot, TEST_OUTPUT_DATA_DIR);
557
- await fs6.mkdir(outputPath, { recursive: true });
558
- const outputFilePath = path10.join(outputPath, TEST_OUTPUT_FILENAME);
559
- const outputData = JSON.stringify(req.body, null, 2);
560
- await fs6.writeFile(outputFilePath, outputData, "utf8");
561
- return res.status(200).json({ success: true });
562
- };
749
+ // src/utils/log-empty-line.ts
750
+ function logEmptyLine() {
751
+ console.log("");
563
752
  }
564
753
 
565
- // src/commands/test/utils/get-input-files.ts
566
- function getInputFiles(config) {
567
- const inputFiles = config.inputs.map((input2) => getInputFilename(input2));
568
- return inputFiles;
754
+ // src/commands/publish/utils/get-model-summary-lines.ts
755
+ import chalk from "chalk";
756
+ function getModelSummaryLines(ctx) {
757
+ return [
758
+ ` ${chalk.bold("Model name:")} ${ctx.config.name}`,
759
+ ` ${chalk.bold("Revision:")} ${ctx.config.$$revision}`,
760
+ ` ${chalk.bold("Forecast variable:")} ${ctx.config.forecastVariable}`,
761
+ ` ${chalk.bold("Time horizon:")} ${ctx.config.timeHorizon}`,
762
+ ` ${chalk.bold("Inputs:")}`,
763
+ ...ctx.config.inputs.map(
764
+ (input2) => ` \u2022 ${input2.key} - ${getInputType(input2)}`
765
+ ),
766
+ ` ${chalk.bold("Outputs:")}`,
767
+ ...ctx.config.outputs.map((output) => ` \u2022 ${output.name} - ${output.type}`),
768
+ ""
769
+ ];
770
+ }
771
+ function getInputType(input2) {
772
+ if (input2.fetcher) {
773
+ return input2.fetcher.type;
774
+ }
775
+ return "static";
776
+ }
777
+
778
+ // src/docker/run-model-container.ts
779
+ import os3 from "os";
780
+ import { execa as execa4 } from "execa";
781
+
782
+ // src/docker/utils/get-input-env.ts
783
+ function getInputEnv(modelInput, webserverURL) {
784
+ const variableName = modelInput.key.toUpperCase();
785
+ const filename = getInputFilename(modelInput);
786
+ const inputFileURL = `${webserverURL}/input/${filename}`;
787
+ return `INPUT_${variableName}_URL=${inputFileURL}`;
569
788
  }
570
789
 
571
- // src/commands/test/utils/model-io-checks.ts
790
+ // src/docker/run-model-container.ts
791
+ async function runModelContainer(dockerImage, webserverPort, ctx) {
792
+ const env = getEnv();
793
+ const webserverHostname = env.isWSL ? DOCKER_HOSTNAME_WSL : DOCKER_HOSTNAME_DEFAULT;
794
+ const webserverURL = `http://${webserverHostname}:${webserverPort}`;
795
+ const inputEnvs = ctx.config.inputs.map(
796
+ (input2) => getInputEnv(input2, webserverURL)
797
+ );
798
+ const outputEnv = `OUTPUT_URL=${webserverURL}/output`;
799
+ const envs = [...inputEnvs, outputEnv];
800
+ try {
801
+ const args = [
802
+ "run",
803
+ "--rm",
804
+ ...envs.flatMap((env2) => ["--env", env2]),
805
+ dockerImage
806
+ ];
807
+ if (env.isWSL) {
808
+ const ip = getWSLMachineIP();
809
+ args.push("--add-host", `${DOCKER_HOSTNAME_WSL}:${ip}`);
810
+ }
811
+ await execa4("docker", args, {
812
+ cwd: ctx.projectRoot,
813
+ stdio: "pipe"
814
+ });
815
+ } catch (error) {
816
+ throw new Error("Failed to run model container", { cause: error });
817
+ }
818
+ }
819
+ function getWSLMachineIP() {
820
+ const env = getEnv();
821
+ const interfaces = os3.networkInterfaces();
822
+ const interfaceInfo = interfaces[env.wslNetworkInterface]?.[0];
823
+ if (!interfaceInfo) {
824
+ throw new Error(
825
+ `WSL machine IP not found for interface \`${env.wslNetworkInterface}\``
826
+ );
827
+ }
828
+ return interfaceInfo.address;
829
+ }
830
+
831
+ // src/model-io-checks/setup-model-io-checks.ts
832
+ import path12 from "path";
833
+ import express from "express";
834
+
835
+ // src/model-io-checks/model-io-checks.ts
572
836
  var ModelIOChecks = class {
573
837
  inputsToDownload;
574
838
  outputUploaded;
@@ -601,85 +865,373 @@ var ModelIOChecks = class {
601
865
  }
602
866
  };
603
867
 
604
- // src/commands/test/utils/run-model-container.ts
605
- import { execa as execa2 } from "execa";
868
+ // src/model-io-checks/utils/create-input-handler.ts
869
+ import path10 from "path";
870
+ function createInputHandler(inputFilesPath, modelIOChecks, ctx) {
871
+ return (req, res) => {
872
+ if (!modelIOChecks.isValidInput(req.params.filename)) {
873
+ return res.status(404).json({ error: "File not found" });
874
+ }
875
+ modelIOChecks.trackInputHandled(req.params.filename);
876
+ const filePath = path10.join(
877
+ ctx.projectRoot,
878
+ inputFilesPath,
879
+ req.params.filename
880
+ );
881
+ return res.sendFile(filePath);
882
+ };
883
+ }
606
884
 
607
- // src/commands/test/utils/get-input-env.ts
608
- function getInputEnv(modelInput, webserverURL) {
609
- const variableName = modelInput.key.toUpperCase();
610
- const filename = getInputFilename(modelInput);
611
- const inputFileURL = `${webserverURL}/input/${filename}`;
612
- return `INPUT_${variableName}_URL=${inputFileURL}`;
885
+ // src/model-io-checks/utils/create-output-handler.ts
886
+ import fs9 from "fs/promises";
887
+ import path11 from "path";
888
+ function createOutputHandler(modelIOChecks, ctx) {
889
+ return async (req, res) => {
890
+ modelIOChecks.trackOutputHandled();
891
+ const outputPath = path11.join(ctx.projectRoot, TEST_OUTPUT_DATA_DIR);
892
+ await fs9.mkdir(outputPath, { recursive: true });
893
+ const outputFilePath = path11.join(outputPath, TEST_OUTPUT_FILENAME);
894
+ const outputData = JSON.stringify(req.body, null, 2);
895
+ await fs9.writeFile(outputFilePath, outputData, "utf8");
896
+ return res.status(200).json({ success: true });
897
+ };
613
898
  }
614
899
 
615
- // src/commands/test/utils/run-model-container.ts
616
- async function runModelContainer(dockerImage, webserverURL, ctx) {
617
- const inputEnvs = ctx.config.inputs.map(
618
- (input2) => getInputEnv(input2, webserverURL)
900
+ // src/model-io-checks/setup-model-io-checks.ts
901
+ function setupModelIOChecks(app, inputDir, inputFiles, ctx) {
902
+ const modelIOChecks = new ModelIOChecks({ inputFiles });
903
+ const handleInput = createInputHandler(inputDir, modelIOChecks, ctx);
904
+ const handleOutput = createOutputHandler(modelIOChecks, ctx);
905
+ const inputPath = path12.join(ctx.projectRoot, inputDir);
906
+ app.use(express.json());
907
+ app.use("/data", express.static(inputPath));
908
+ app.get("/input/:filename", handleInput);
909
+ app.put("/output", handleOutput);
910
+ return modelIOChecks;
911
+ }
912
+
913
+ // src/utils/check-input-files.ts
914
+ import path13 from "path";
915
+ async function checkInputFiles(inputFiles, inputDataPath, ctx) {
916
+ for (const inputFile of inputFiles) {
917
+ const filePath = path13.join(ctx.projectRoot, inputDataPath, inputFile);
918
+ const exists = await isExistingPath(filePath);
919
+ if (!exists) {
920
+ throw new Error(
921
+ `Input data not found (${inputFile}) - did you need to run \`pd4castr fetch\`?`
922
+ );
923
+ }
924
+ }
925
+ }
926
+
927
+ // src/utils/get-input-files.ts
928
+ function getInputFiles(config) {
929
+ const inputFiles = config.inputs.map((input2) => getInputFilename(input2));
930
+ return inputFiles;
931
+ }
932
+
933
+ // src/commands/publish/utils/run-model-io-tests.ts
934
+ async function runModelIOTests(dockerImage, options, app, ctx) {
935
+ const inputFiles = getInputFiles(ctx.config);
936
+ await checkInputFiles(inputFiles, options.inputDir, ctx);
937
+ await buildDockerImage(dockerImage, ctx);
938
+ const modelIOChecks = setupModelIOChecks(
939
+ app,
940
+ options.inputDir,
941
+ inputFiles,
942
+ ctx
619
943
  );
620
- const outputEnv = `OUTPUT_URL=${webserverURL}/output`;
621
- const envs = [...inputEnvs, outputEnv];
622
- try {
623
- const args = [
624
- "run",
625
- "--rm",
626
- ...envs.flatMap((env) => ["--env", env]),
627
- dockerImage
628
- ];
629
- await execa2("docker", args, {
630
- cwd: ctx.projectRoot,
631
- stdio: "pipe"
632
- });
633
- } catch (error) {
634
- throw new Error("Failed to run model container", { cause: error });
944
+ await runModelContainer(dockerImage, options.port, ctx);
945
+ if (!modelIOChecks.isInputsHandled() || !modelIOChecks.isOutputHandled()) {
946
+ throw new Error(
947
+ "Model I/O test failed. Please run `pd4castr test` to debug the issue."
948
+ );
635
949
  }
636
950
  }
637
951
 
638
- // src/commands/test/utils/start-web-server.ts
639
- async function startWebServer(app, port) {
640
- return new Promise((resolve) => {
641
- const server = app.listen(port, () => {
642
- resolve({
643
- server,
644
- [Symbol.dispose]: () => server.close()
645
- });
646
- });
952
+ // src/commands/publish/handle-create-model-flow.ts
953
+ async function handleCreateModelFlow(options, app, spinner, ctx, authCtx) {
954
+ spinner.info(`You are publishing a ${chalk2.bold("new")} model:
955
+ `);
956
+ getModelSummaryLines(ctx).map((line) => console.log(line));
957
+ const confirm4 = await inquirer2.confirm({
958
+ message: "Do you want to continue?",
959
+ default: false
960
+ });
961
+ logEmptyLine();
962
+ if (!confirm4) {
963
+ throw new Error("Aborted");
964
+ }
965
+ const dockerImage = getDockerImage(ctx);
966
+ if (!options.skipChecks) {
967
+ spinner.start("Performing model I/O test...");
968
+ await runModelIOTests(dockerImage, options, app, ctx);
969
+ spinner.succeed("Model I/O test passed");
970
+ }
971
+ spinner.start("Publishing model...");
972
+ const modelConfig = await getModelConfigFromProjectConfig(ctx);
973
+ const model = await createModel(modelConfig, authCtx);
974
+ await updateProjectConfig((config) => {
975
+ config.$$id = model.id;
976
+ config.$$modelGroupID = model.modelGroupId;
977
+ config.$$revision = model.revision;
978
+ config.$$dockerImage = model.dockerImage;
979
+ });
980
+ const pushCredentials = await getRegistryPushCredentials(model.id, authCtx);
981
+ await loginToDockerRegistry(pushCredentials);
982
+ await buildDockerImage(dockerImage, ctx);
983
+ await pushDockerImage(dockerImage, pushCredentials.ref);
984
+ spinner.stopAndPersist({
985
+ symbol: "\u{1F680} ",
986
+ prefixText: "\n",
987
+ suffixText: "\n",
988
+ text: chalk2.bold("Model published successfully")
647
989
  });
648
990
  }
649
991
 
992
+ // src/commands/publish/handle-update-existing-model-flow.ts
993
+ import * as inquirer5 from "@inquirer/prompts";
994
+ import chalk5 from "chalk";
995
+
996
+ // src/commands/publish/handle-model-revision-create-flow.ts
997
+ import * as inquirer3 from "@inquirer/prompts";
998
+ import chalk3 from "chalk";
999
+
1000
+ // src/commands/publish/utils/validate-local-model-state.ts
1001
+ import invariant2 from "tiny-invariant";
1002
+
1003
+ // src/api/get-model.ts
1004
+ async function getModel(id, authCtx) {
1005
+ const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
1006
+ const result = await api.get(`model/${id}`, { headers }).json();
1007
+ return result;
1008
+ }
1009
+
1010
+ // src/commands/publish/utils/validate-local-model-state.ts
1011
+ async function validateLocalModelState(ctx, authCtx) {
1012
+ invariant2(ctx.config.$$id, "model ID is required to fetch published model");
1013
+ const currentModel = await getModel(ctx.config.$$id, authCtx);
1014
+ if (currentModel.revision !== ctx.config.$$revision) {
1015
+ throw new Error(
1016
+ `OUT OF SYNC: Local revision (${ctx.config.$$revision}) does not match the current published revision (${currentModel.revision})`
1017
+ );
1018
+ }
1019
+ if (currentModel.modelGroupId !== ctx.config.$$modelGroupID) {
1020
+ throw new Error(
1021
+ `OUT OF SYNC: Local model group ID (${ctx.config.$$modelGroupID}) does not match the current published model group ID (${currentModel.modelGroupId})`
1022
+ );
1023
+ }
1024
+ }
1025
+
1026
+ // src/commands/publish/handle-model-revision-create-flow.ts
1027
+ var WARNING_LABEL = chalk3.yellowBright.bold("WARNING!");
1028
+ var CONFIRMATION_MESSAGE = `${WARNING_LABEL} Creating a new revision will preserve existing revisions.
1029
+ Previous revisions will still be available in the pd4castr UI.
1030
+ `;
1031
+ async function handleModelRevisionCreateFlow(options, app, spinner, ctx, authCtx) {
1032
+ console.log(CONFIRMATION_MESSAGE);
1033
+ const confirmed = await inquirer3.confirm({
1034
+ message: "Are you sure you want to continue?",
1035
+ default: false
1036
+ });
1037
+ logEmptyLine();
1038
+ if (!confirmed) {
1039
+ throw new Error("Model revision update cancelled");
1040
+ }
1041
+ spinner.start("Validating local model state...");
1042
+ await validateLocalModelState(ctx, authCtx);
1043
+ spinner.succeed("Local model is synced with published model");
1044
+ const dockerImage = getDockerImage(ctx);
1045
+ if (!options.skipChecks) {
1046
+ spinner.start("Performing model I/O test...");
1047
+ await runModelIOTests(dockerImage, options, app, ctx);
1048
+ spinner.succeed("Model I/O test passed");
1049
+ }
1050
+ const modelConfig = await getModelConfigFromProjectConfig(ctx);
1051
+ const model = await createModel(modelConfig, authCtx);
1052
+ await updateProjectConfig((config) => {
1053
+ config.$$id = model.id;
1054
+ config.$$modelGroupID = model.modelGroupId;
1055
+ config.$$revision = model.revision;
1056
+ config.$$dockerImage = model.dockerImage;
1057
+ });
1058
+ const pushCredentials = await getRegistryPushCredentials(model.id, authCtx);
1059
+ await loginToDockerRegistry(pushCredentials);
1060
+ await buildDockerImage(dockerImage, ctx);
1061
+ await pushDockerImage(dockerImage, pushCredentials.ref);
1062
+ spinner.stopAndPersist({
1063
+ symbol: "\u{1F680} ",
1064
+ prefixText: "\n",
1065
+ suffixText: "\n",
1066
+ text: chalk3.bold(
1067
+ `New model revision (r${model.revision}) published successfully`
1068
+ )
1069
+ });
1070
+ }
1071
+
1072
+ // src/commands/publish/handle-model-revision-update-flow.ts
1073
+ import * as inquirer4 from "@inquirer/prompts";
1074
+ import chalk4 from "chalk";
1075
+
1076
+ // src/api/update-model.ts
1077
+ async function updateModel(config, authCtx) {
1078
+ const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
1079
+ const result = await api.patch(`model/${config.id}`, { headers, json: config }).json();
1080
+ return result;
1081
+ }
1082
+
1083
+ // src/commands/publish/handle-model-revision-update-flow.ts
1084
+ var WARNING_LABEL2 = chalk4.yellowBright.bold("WARNING!");
1085
+ var CONFIRMATION_MESSAGE2 = `${WARNING_LABEL2} Updating a model revision recreates the associated inputs and outputs.
1086
+ Historical data is preserved, but it will no longer be displayed in the pd4castr UI.
1087
+ `;
1088
+ async function handleModelRevisionUpdateFlow(options, app, spinner, ctx, authCtx) {
1089
+ console.log(CONFIRMATION_MESSAGE2);
1090
+ const confirmed = await inquirer4.confirm({
1091
+ message: "Are you sure you want to continue?",
1092
+ default: false
1093
+ });
1094
+ logEmptyLine();
1095
+ if (!confirmed) {
1096
+ throw new Error("Model revision update cancelled");
1097
+ }
1098
+ spinner.start("Validating local model state...");
1099
+ await validateLocalModelState(ctx, authCtx);
1100
+ spinner.succeed("Local model is synced with published model");
1101
+ const dockerImage = getDockerImage(ctx);
1102
+ if (!options.skipChecks) {
1103
+ spinner.start("Performing model I/O test...");
1104
+ await runModelIOTests(dockerImage, options, app, ctx);
1105
+ spinner.succeed("Model I/O test passed");
1106
+ }
1107
+ const modelConfig = await getModelConfigFromProjectConfig(ctx);
1108
+ const model = await updateModel(modelConfig, authCtx);
1109
+ await updateProjectConfig((config) => {
1110
+ config.$$id = model.id;
1111
+ config.$$modelGroupID = model.modelGroupId;
1112
+ config.$$revision = model.revision;
1113
+ config.$$dockerImage = model.dockerImage;
1114
+ });
1115
+ const pushCredentials = await getRegistryPushCredentials(model.id, authCtx);
1116
+ await loginToDockerRegistry(pushCredentials);
1117
+ await buildDockerImage(dockerImage, ctx);
1118
+ await pushDockerImage(dockerImage, pushCredentials.ref);
1119
+ spinner.stopAndPersist({
1120
+ symbol: "\u{1F680} ",
1121
+ prefixText: "\n",
1122
+ suffixText: "\n",
1123
+ text: chalk4.bold(`${model.name} (r${model.revision}) updated successfully`)
1124
+ });
1125
+ }
1126
+
1127
+ // src/commands/publish/handle-update-existing-model-flow.ts
1128
+ async function handleUpdateExistingModelFlow(options, app, spinner, ctx, authCtx) {
1129
+ spinner.info(`You are publishing an ${chalk5.bold("existing")} model:
1130
+ `);
1131
+ getModelSummaryLines(ctx).map((line) => console.log(line));
1132
+ const revision = ctx.config.$$revision ?? 0;
1133
+ const action = await inquirer5.select({
1134
+ message: "Do you want to update the existing revision or create a new one?",
1135
+ choices: [
1136
+ {
1137
+ value: "new" /* NewRevision */,
1138
+ name: `New Revision (r${revision} \u2192 r${revision + 1})`
1139
+ },
1140
+ {
1141
+ value: "update" /* UpdateExisting */,
1142
+ name: `Update Existing Revision (r${revision})`
1143
+ }
1144
+ ]
1145
+ });
1146
+ logEmptyLine();
1147
+ if (action === "new" /* NewRevision */) {
1148
+ await handleModelRevisionCreateFlow(options, app, spinner, ctx, authCtx);
1149
+ } else if (action === "update" /* UpdateExisting */) {
1150
+ await handleModelRevisionUpdateFlow(options, app, spinner, ctx, authCtx);
1151
+ } else {
1152
+ throw new Error("Invalid CLI state");
1153
+ }
1154
+ }
1155
+
1156
+ // src/commands/publish/handle-action.ts
1157
+ async function handleAction5(options) {
1158
+ var _stack = [];
1159
+ try {
1160
+ const spinner = ora5("Starting model publish...").start();
1161
+ const app = express2();
1162
+ app.use(express2.json({ limit: "100mb" }));
1163
+ app.use(express2.urlencoded({ limit: "100mb", extended: true }));
1164
+ const webServer = __using(_stack, await startWebServer(app, options.port));
1165
+ try {
1166
+ const ctx = await loadProjectContext();
1167
+ const authCtx = await getAuth();
1168
+ await (ctx.config.$$id ? handleUpdateExistingModelFlow(options, app, spinner, ctx, authCtx) : handleCreateModelFlow(options, app, spinner, ctx, authCtx));
1169
+ } catch (error) {
1170
+ if (error instanceof ZodError5) {
1171
+ spinner.fail("Config validation failed");
1172
+ logZodIssues(error);
1173
+ } else if (error instanceof HTTPError3) {
1174
+ const errorResponse = await error.response.json();
1175
+ spinner.fail(formatNestErrorMessage(errorResponse));
1176
+ } else if (error instanceof Error) {
1177
+ spinner.fail(error.message);
1178
+ if (error.cause) {
1179
+ console.error(error.cause);
1180
+ }
1181
+ }
1182
+ process.exit(1);
1183
+ }
1184
+ } catch (_) {
1185
+ var _error = _, _hasError = true;
1186
+ } finally {
1187
+ __callDispose(_stack, _error, _hasError);
1188
+ }
1189
+ }
1190
+
1191
+ // src/commands/publish/index.ts
1192
+ function registerPublishCommand(program2) {
1193
+ program2.command("publish").description("Publishes a pd4castr model.").option(
1194
+ "-i, --input-dir <path>",
1195
+ "The input test data directory",
1196
+ TEST_INPUT_DATA_DIR
1197
+ ).option(
1198
+ "-p, --port <port>",
1199
+ "The port to run the IO testing webserver on",
1200
+ TEST_WEBSERVER_PORT.toString()
1201
+ ).option("-s, --skip-checks", "Skip the model I/O checks", false).action(handleAction5);
1202
+ }
1203
+
650
1204
  // src/commands/test/handle-action.ts
651
- async function handleAction4(options) {
1205
+ import path14 from "path";
1206
+ import express3 from "express";
1207
+ import ora6 from "ora";
1208
+ import { ZodError as ZodError6 } from "zod";
1209
+ async function handleAction6(options) {
652
1210
  var _stack = [];
653
1211
  try {
654
- const spinner = ora4("Starting model tests...").info();
655
- const app = express();
1212
+ const spinner = ora6("Starting model tests...").info();
1213
+ const app = express3();
1214
+ app.use(express3.json({ limit: "100mb" }));
1215
+ app.use(express3.urlencoded({ limit: "100mb", extended: true }));
656
1216
  const webServer = __using(_stack, await startWebServer(app, options.port));
657
1217
  try {
658
- const ctx = await loadProjectConfig();
1218
+ const ctx = await loadProjectContext();
659
1219
  const inputFiles = getInputFiles(ctx.config);
660
1220
  spinner.start("Checking test input data files");
661
1221
  await checkInputFiles(inputFiles, options.inputDir, ctx);
662
1222
  spinner.succeed(`Found ${inputFiles.length} test input data files`);
663
1223
  spinner.start("Building docker image");
664
- const sluggedName = slugify(ctx.config.name, { lower: true });
665
- const dockerImage = `pd4castr/${sluggedName}-local:${Date.now()}`;
1224
+ const dockerImage = getDockerImage(ctx);
666
1225
  await buildDockerImage(dockerImage, ctx);
667
1226
  spinner.succeed(`Built docker image (${dockerImage})`);
668
- const modelIOChecks = new ModelIOChecks({ inputFiles });
669
- const handleInput = createInputHandler(
1227
+ const modelIOChecks = setupModelIOChecks(
1228
+ app,
670
1229
  options.inputDir,
671
- modelIOChecks,
1230
+ inputFiles,
672
1231
  ctx
673
1232
  );
674
- const handleOutput = createOutputHandler(modelIOChecks, ctx);
675
- const inputPath = path11.join(ctx.projectRoot, options.inputDir);
676
- app.use(express.json());
677
- app.use("/data", express.static(inputPath));
678
- app.get("/input/:filename", handleInput);
679
- app.put("/output", handleOutput);
680
1233
  spinner.start("Running model container");
681
- const webserverURL = `http://host.docker.internal:${options.port}`;
682
- await runModelContainer(dockerImage, webserverURL, ctx);
1234
+ await runModelContainer(dockerImage, options.port, ctx);
683
1235
  spinner.succeed("Model run complete");
684
1236
  for (const inputFile of inputFiles) {
685
1237
  const status = modelIOChecks.isInputHandled(inputFile) ? "\u2714" : "\u2718";
@@ -690,10 +1242,10 @@ async function handleAction4(options) {
690
1242
  if (modelIOChecks.isInputsHandled() && modelIOChecks.isOutputHandled()) {
691
1243
  spinner.succeed("Model I/O test passed");
692
1244
  } else {
693
- spinner.fail("Model I/O test failed");
1245
+ throw new Error("Model I/O test failed");
694
1246
  }
695
1247
  if (modelIOChecks.isOutputHandled()) {
696
- const outputPath = path11.join(
1248
+ const outputPath = path14.join(
697
1249
  ctx.projectRoot,
698
1250
  TEST_OUTPUT_DATA_DIR,
699
1251
  TEST_OUTPUT_FILENAME
@@ -705,14 +1257,14 @@ ${clickHereLink} to view output (${fileLink})
705
1257
  `);
706
1258
  }
707
1259
  } catch (error) {
708
- if (error instanceof ZodError3) {
1260
+ if (error instanceof ZodError6) {
709
1261
  spinner.fail("Config validation failed");
710
1262
  logZodIssues(error);
711
1263
  } else if (error instanceof Error) {
712
1264
  spinner.fail(error.message);
713
- }
714
- if (error instanceof Error && error.cause instanceof ExecaError) {
715
- console.error(error.cause.stderr);
1265
+ if (error.cause) {
1266
+ console.error(error.cause);
1267
+ }
716
1268
  }
717
1269
  process.exit(1);
718
1270
  }
@@ -731,7 +1283,11 @@ function registerTestCommand(program2) {
731
1283
  "-i, --input-dir <path>",
732
1284
  "The input test data directory",
733
1285
  TEST_INPUT_DATA_DIR
734
- ).option("-p, --port <port>", "The port to run the webserver on", "9800").action(handleAction4);
1286
+ ).option(
1287
+ "-p, --port <port>",
1288
+ "The port to run the IO testing webserver on",
1289
+ TEST_WEBSERVER_PORT.toString()
1290
+ ).action(handleAction6);
735
1291
  }
736
1292
 
737
1293
  // src/program.ts
@@ -740,7 +1296,7 @@ import { Command } from "commander";
740
1296
  // package.json
741
1297
  var package_default = {
742
1298
  name: "@pd4castr/cli",
743
- version: "0.0.5",
1299
+ version: "0.0.6",
744
1300
  description: "CLI tool for creating, testing, and publishing pd4castr models",
745
1301
  main: "dist/index.js",
746
1302
  type: "module",
@@ -761,7 +1317,7 @@ var package_default = {
761
1317
  "lint:fix": "eslint . --fix",
762
1318
  format: "prettier --write .",
763
1319
  "format:check": "prettier --check .",
764
- "type-check": "tsc --noEmit",
1320
+ typecheck: "tsc --noEmit",
765
1321
  prepublishOnly: "yarn build"
766
1322
  },
767
1323
  keywords: [
@@ -778,6 +1334,8 @@ var package_default = {
778
1334
  },
779
1335
  homepage: "https://github.com/pipelabs/pd4castr-cli#readme",
780
1336
  devDependencies: {
1337
+ "@faker-js/faker": "10.0.0",
1338
+ "@mswjs/data": "0.16.2",
781
1339
  "@types/express": "4.17.21",
782
1340
  "@types/node": "24.1.0",
783
1341
  "@types/supertest": "6.0.3",
@@ -788,12 +1346,15 @@ var package_default = {
788
1346
  "eslint-plugin-simple-import-sort": "12.1.1",
789
1347
  "eslint-plugin-unicorn": "60.0.0",
790
1348
  "eslint-plugin-vitest": "0.5.4",
1349
+ "hook-std": "3.0.0",
791
1350
  "jest-extended": "6.0.0",
792
1351
  memfs: "4.23.0",
793
1352
  msw: "2.10.4",
794
1353
  prettier: "3.6.2",
1354
+ "strip-ansi": "7.1.0",
795
1355
  supertest: "7.1.4",
796
1356
  tsup: "8.5.0",
1357
+ "type-fest": "4.41.0",
797
1358
  typescript: "5.8.3",
798
1359
  "typescript-eslint": "8.38.0",
799
1360
  vitest: "3.2.4"
@@ -801,11 +1362,12 @@ var package_default = {
801
1362
  dependencies: {
802
1363
  "@inquirer/prompts": "7.7.1",
803
1364
  auth0: "4.27.0",
1365
+ chalk: "5.6.0",
804
1366
  commander: "14.0.0",
805
1367
  execa: "9.6.0",
806
1368
  express: "4.21.2",
1369
+ immer: "10.1.1",
807
1370
  ky: "1.8.2",
808
- lilconfig: "3.1.3",
809
1371
  ora: "8.2.0",
810
1372
  slugify: "1.6.6",
811
1373
  tiged: "2.12.7",
@@ -824,6 +1386,8 @@ program.name("pd4castr").description("CLI tool for pd4castr").version(package_de
824
1386
  // src/index.ts
825
1387
  registerInitCommand(program);
826
1388
  registerLoginCommand(program);
1389
+ registerLogoutCommand(program);
827
1390
  registerTestCommand(program);
828
1391
  registerFetchCommand(program);
1392
+ registerPublishCommand(program);
829
1393
  await program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pd4castr/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "CLI tool for creating, testing, and publishing pd4castr models",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "lint:fix": "eslint . --fix",
22
22
  "format": "prettier --write .",
23
23
  "format:check": "prettier --check .",
24
- "type-check": "tsc --noEmit",
24
+ "typecheck": "tsc --noEmit",
25
25
  "prepublishOnly": "yarn build"
26
26
  },
27
27
  "keywords": [
@@ -38,6 +38,8 @@
38
38
  },
39
39
  "homepage": "https://github.com/pipelabs/pd4castr-cli#readme",
40
40
  "devDependencies": {
41
+ "@faker-js/faker": "10.0.0",
42
+ "@mswjs/data": "0.16.2",
41
43
  "@types/express": "4.17.21",
42
44
  "@types/node": "24.1.0",
43
45
  "@types/supertest": "6.0.3",
@@ -48,12 +50,15 @@
48
50
  "eslint-plugin-simple-import-sort": "12.1.1",
49
51
  "eslint-plugin-unicorn": "60.0.0",
50
52
  "eslint-plugin-vitest": "0.5.4",
53
+ "hook-std": "3.0.0",
51
54
  "jest-extended": "6.0.0",
52
55
  "memfs": "4.23.0",
53
56
  "msw": "2.10.4",
54
57
  "prettier": "3.6.2",
58
+ "strip-ansi": "7.1.0",
55
59
  "supertest": "7.1.4",
56
60
  "tsup": "8.5.0",
61
+ "type-fest": "4.41.0",
57
62
  "typescript": "5.8.3",
58
63
  "typescript-eslint": "8.38.0",
59
64
  "vitest": "3.2.4"
@@ -61,11 +66,12 @@
61
66
  "dependencies": {
62
67
  "@inquirer/prompts": "7.7.1",
63
68
  "auth0": "4.27.0",
69
+ "chalk": "5.6.0",
64
70
  "commander": "14.0.0",
65
71
  "execa": "9.6.0",
66
72
  "express": "4.21.2",
73
+ "immer": "10.1.1",
67
74
  "ky": "1.8.2",
68
- "lilconfig": "3.1.3",
69
75
  "ora": "8.2.0",
70
76
  "slugify": "1.6.6",
71
77
  "tiged": "2.12.7",