@pd4castr/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.
- package/README.md +47 -0
- package/dist/index.js +699 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# pd4castr CLI
|
|
2
|
+
|
|
3
|
+
CLI tool for creating, testing, and publishing pd4castr models.
|
|
4
|
+
|
|
5
|
+
Install via:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @pd4castr/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Contributing
|
|
12
|
+
|
|
13
|
+
### Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# set this repository up for linking during develpment
|
|
17
|
+
yarn link
|
|
18
|
+
|
|
19
|
+
# run the project in watch mode
|
|
20
|
+
yarn dev
|
|
21
|
+
|
|
22
|
+
# from a model project, link the module
|
|
23
|
+
yarn link @pd4castr/cli
|
|
24
|
+
|
|
25
|
+
# from that project, execute a command
|
|
26
|
+
yarn pd4castr <command>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Scripts
|
|
30
|
+
|
|
31
|
+
- `yarn build` - Build the project
|
|
32
|
+
- `yarn dev` - Watch mode for development
|
|
33
|
+
- `yarn cli <command>` - Run CLI commands against local build
|
|
34
|
+
- `yarn test` - Run tests once
|
|
35
|
+
- `yarn test:watch` - Run tests in watch mode
|
|
36
|
+
- `yarn lint` - Check for linting issues
|
|
37
|
+
- `yarn format` - Format code with Prettier
|
|
38
|
+
- `yarn type-check` - TypeScript type checking
|
|
39
|
+
|
|
40
|
+
### Testing
|
|
41
|
+
|
|
42
|
+
As this project requires a lot of disk I/O and network reqeuests, we opt for 2 mocking solutions that keep us as close to the metal as possible:
|
|
43
|
+
|
|
44
|
+
- network requests are mocked uses [msw](https://mswjs.io/) - request handlers live in the [mocks/handlers](./src/mocks/handlers/) folder
|
|
45
|
+
- disk I/O (`fs`) is mocked using [`memfs`](https://github.com/streamich/memfs) which is handled by Vitest in our [`__mocks__/`](./src/__mocks__) folder
|
|
46
|
+
|
|
47
|
+
Both of these mocks are initialised globally via our [setup script](./vitest.setup.ts).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/program.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// package.json
|
|
7
|
+
var package_default = {
|
|
8
|
+
name: "@pd4castr/cli",
|
|
9
|
+
version: "0.0.1",
|
|
10
|
+
description: "CLI tool for creating, testing, and publishing pd4castr models",
|
|
11
|
+
main: "dist/index.js",
|
|
12
|
+
type: "module",
|
|
13
|
+
bin: {
|
|
14
|
+
pd4castr: "dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
files: [
|
|
17
|
+
"dist/**/*"
|
|
18
|
+
],
|
|
19
|
+
scripts: {
|
|
20
|
+
build: "tsup",
|
|
21
|
+
dev: "tsup --watch",
|
|
22
|
+
cli: "node dist/index.js",
|
|
23
|
+
test: "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"test:coverage": "vitest run --coverage",
|
|
26
|
+
lint: "eslint .",
|
|
27
|
+
"lint:fix": "eslint . --fix",
|
|
28
|
+
format: "prettier --write .",
|
|
29
|
+
"format:check": "prettier --check .",
|
|
30
|
+
"type-check": "tsc --noEmit",
|
|
31
|
+
prepublishOnly: "yarn build"
|
|
32
|
+
},
|
|
33
|
+
keywords: [
|
|
34
|
+
"cli",
|
|
35
|
+
"pd4castr"
|
|
36
|
+
],
|
|
37
|
+
license: "UNLICENSED",
|
|
38
|
+
repository: {
|
|
39
|
+
type: "git",
|
|
40
|
+
url: "git+https://github.com/pipelabs/pd4castr-cli.git"
|
|
41
|
+
},
|
|
42
|
+
bugs: {
|
|
43
|
+
url: "https://github.com/pipelabs/pd4castr-cli/issues"
|
|
44
|
+
},
|
|
45
|
+
homepage: "https://github.com/pipelabs/pd4castr-cli#readme",
|
|
46
|
+
devDependencies: {
|
|
47
|
+
"@types/express": "4.17.21",
|
|
48
|
+
"@types/node": "24.1.0",
|
|
49
|
+
"@types/supertest": "6.0.3",
|
|
50
|
+
"@typescript-eslint/eslint-plugin": "8.38.0",
|
|
51
|
+
"@typescript-eslint/parser": "8.38.0",
|
|
52
|
+
eslint: "9.32.0",
|
|
53
|
+
"eslint-config-prettier": "10.1.8",
|
|
54
|
+
"eslint-plugin-unicorn": "60.0.0",
|
|
55
|
+
"eslint-plugin-vitest": "0.5.4",
|
|
56
|
+
"jest-extended": "6.0.0",
|
|
57
|
+
memfs: "4.23.0",
|
|
58
|
+
msw: "2.10.4",
|
|
59
|
+
prettier: "3.6.2",
|
|
60
|
+
supertest: "7.1.4",
|
|
61
|
+
tsup: "8.5.0",
|
|
62
|
+
typescript: "5.8.3",
|
|
63
|
+
"typescript-eslint": "8.38.0",
|
|
64
|
+
vitest: "3.2.4"
|
|
65
|
+
},
|
|
66
|
+
dependencies: {
|
|
67
|
+
"@inquirer/prompts": "7.7.1",
|
|
68
|
+
auth0: "4.27.0",
|
|
69
|
+
commander: "14.0.0",
|
|
70
|
+
execa: "9.6.0",
|
|
71
|
+
express: "4.21.2",
|
|
72
|
+
ky: "1.8.2",
|
|
73
|
+
lilconfig: "3.1.3",
|
|
74
|
+
ora: "8.2.0",
|
|
75
|
+
tiged: "2.12.7",
|
|
76
|
+
"tiny-invariant": "1.3.3",
|
|
77
|
+
zod: "4.0.14"
|
|
78
|
+
},
|
|
79
|
+
engines: {
|
|
80
|
+
node: ">=20.0.0"
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/program.ts
|
|
85
|
+
var program = new Command();
|
|
86
|
+
program.name("pd4castr").description("CLI tool for pd4castr").version(package_default.version);
|
|
87
|
+
|
|
88
|
+
// src/commands/init/handle-action.ts
|
|
89
|
+
import path from "path";
|
|
90
|
+
import * as inquirer from "@inquirer/prompts";
|
|
91
|
+
import tiged from "tiged";
|
|
92
|
+
import ora from "ora";
|
|
93
|
+
|
|
94
|
+
// src/commands/init/constants.ts
|
|
95
|
+
var templates = {
|
|
96
|
+
"python-barebones": {
|
|
97
|
+
name: "python-barebones",
|
|
98
|
+
repo: "pipelabs/pd4castr-model-examples",
|
|
99
|
+
path: "examples/python-barebones"
|
|
100
|
+
},
|
|
101
|
+
"python-demo": {
|
|
102
|
+
name: "python-demo",
|
|
103
|
+
repo: "pipelabs/pd4castr-model-examples",
|
|
104
|
+
path: "examples/python-demo"
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/commands/init/utils/get-template-path.ts
|
|
109
|
+
function getTemplatePath(template) {
|
|
110
|
+
return `https://github.com/${template.repo}/${template.path}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/utils/is-existing-path.ts
|
|
114
|
+
import fs from "fs/promises";
|
|
115
|
+
async function isExistingPath(path8) {
|
|
116
|
+
try {
|
|
117
|
+
await fs.access(path8);
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/commands/init/utils/validate-name.ts
|
|
125
|
+
async function validateName(value) {
|
|
126
|
+
const exists = await isExistingPath(`./${value}`);
|
|
127
|
+
if (exists) {
|
|
128
|
+
return "A directory or file with this name already exists. Please choose a different name.";
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/commands/init/handle-action.ts
|
|
134
|
+
async function handleAction() {
|
|
135
|
+
const projectName = await inquirer.input({
|
|
136
|
+
message: "Name your new model project",
|
|
137
|
+
default: "my-model",
|
|
138
|
+
validate: validateName
|
|
139
|
+
});
|
|
140
|
+
const template = await inquirer.select({
|
|
141
|
+
message: "Select a template",
|
|
142
|
+
choices: Object.values(templates).map((template2) => ({
|
|
143
|
+
name: template2.name,
|
|
144
|
+
value: template2.name
|
|
145
|
+
}))
|
|
146
|
+
});
|
|
147
|
+
const spinner = ora("Fetching template...").start();
|
|
148
|
+
try {
|
|
149
|
+
await fetchTemplate(template, projectName);
|
|
150
|
+
spinner.succeed("Template fetched successfully");
|
|
151
|
+
} catch {
|
|
152
|
+
spinner.fail("Error fetching template");
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function fetchTemplate(template, projectName) {
|
|
157
|
+
const templatePath = getTemplatePath(templates[template]);
|
|
158
|
+
const fetcher = tiged(templatePath, {
|
|
159
|
+
disableCache: true,
|
|
160
|
+
force: true
|
|
161
|
+
});
|
|
162
|
+
const destination = path.join(process.cwd(), projectName);
|
|
163
|
+
await fetcher.clone(destination);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/commands/init/index.ts
|
|
167
|
+
function registerInitCommand(program2) {
|
|
168
|
+
program2.command("init").description("Initialize a new model using a template.").action(handleAction);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/commands/login/handle-action.ts
|
|
172
|
+
import ora2 from "ora";
|
|
173
|
+
import { ZodError } from "zod";
|
|
174
|
+
|
|
175
|
+
// src/config/load-global-config.ts
|
|
176
|
+
import fs2 from "fs/promises";
|
|
177
|
+
import path2 from "path";
|
|
178
|
+
import os from "os";
|
|
179
|
+
|
|
180
|
+
// src/constants.ts
|
|
181
|
+
var AUTH0_DOMAIN = "pdview.au.auth0.com";
|
|
182
|
+
var AUTH0_CLIENT_ID = "Q5tQNF57cQlVXnVsqnU0hhgy92rVb03W";
|
|
183
|
+
var AUTH0_AUDIENCE = "https://api.pd4castr.com.au";
|
|
184
|
+
var GLOBAL_CONFIG_FILE = ".pd4castr";
|
|
185
|
+
var PROJECT_CONFIG_FILE = ".pd4castrrc.json";
|
|
186
|
+
var API_URL = "https://api.pd4castr.com.au";
|
|
187
|
+
var TEST_INPUT_DATA_DIR = "test_data";
|
|
188
|
+
|
|
189
|
+
// src/schemas/global-config-schema.ts
|
|
190
|
+
import { z } from "zod";
|
|
191
|
+
var globalConfigSchema = z.object({
|
|
192
|
+
accessToken: z.string().optional(),
|
|
193
|
+
accessTokenExpiresAt: z.number().optional()
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// src/config/load-global-config.ts
|
|
197
|
+
async function loadGlobalConfig() {
|
|
198
|
+
const configPath = path2.join(os.homedir(), GLOBAL_CONFIG_FILE);
|
|
199
|
+
const configExists = await isExistingPath(configPath);
|
|
200
|
+
if (!configExists) {
|
|
201
|
+
await fs2.writeFile(configPath, JSON.stringify({}));
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const configFileContents = await fs2.readFile(configPath, "utf8");
|
|
206
|
+
const config = JSON.parse(configFileContents);
|
|
207
|
+
return globalConfigSchema.parse(config);
|
|
208
|
+
} catch {
|
|
209
|
+
return {};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/config/update-global-config.ts
|
|
214
|
+
import fs3 from "fs/promises";
|
|
215
|
+
import path3 from "path";
|
|
216
|
+
import os2 from "os";
|
|
217
|
+
async function updateGlobalConfig(updatedConfig) {
|
|
218
|
+
const configPath = path3.join(os2.homedir(), GLOBAL_CONFIG_FILE);
|
|
219
|
+
await fs3.writeFile(configPath, JSON.stringify(updatedConfig, void 0, 2));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/utils/log-zod-issues.ts
|
|
223
|
+
function logZodIssues(error) {
|
|
224
|
+
for (const issue of error.issues) {
|
|
225
|
+
console.log(` \u2718 ${issue.path.join(".")} - ${issue.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/commands/login/utils/is-authed.ts
|
|
230
|
+
function isAuthed(config) {
|
|
231
|
+
const isTokenExpired = config.accessTokenExpiresAt && config.accessTokenExpiresAt <= Date.now();
|
|
232
|
+
return Boolean(config.accessToken) && !isTokenExpired;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/commands/login/auth0-api.ts
|
|
236
|
+
import ky from "ky";
|
|
237
|
+
var auth0API = ky.create({ prefixUrl: `https://${AUTH0_DOMAIN}` });
|
|
238
|
+
|
|
239
|
+
// src/commands/login/utils/start-auth-flow.ts
|
|
240
|
+
var payload = {
|
|
241
|
+
client_id: AUTH0_CLIENT_ID,
|
|
242
|
+
audience: AUTH0_AUDIENCE,
|
|
243
|
+
scope: "openid profile"
|
|
244
|
+
};
|
|
245
|
+
async function startAuthFlow() {
|
|
246
|
+
const codeResponse = await auth0API.post("oauth/device/code", { json: payload }).json();
|
|
247
|
+
const authContext = {
|
|
248
|
+
deviceCode: codeResponse.device_code,
|
|
249
|
+
verificationURL: codeResponse.verification_uri_complete,
|
|
250
|
+
userCode: codeResponse.user_code,
|
|
251
|
+
checkInterval: codeResponse.interval
|
|
252
|
+
};
|
|
253
|
+
return authContext;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/commands/login/utils/complete-auth-flow.ts
|
|
257
|
+
import { HTTPError } from "ky";
|
|
258
|
+
var FAILED_AUTH_ERRORS = /* @__PURE__ */ new Set(["expired_token", "access_denied"]);
|
|
259
|
+
async function completeAuthFlow(authCtx) {
|
|
260
|
+
const payload2 = {
|
|
261
|
+
client_id: AUTH0_CLIENT_ID,
|
|
262
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
263
|
+
device_code: authCtx.deviceCode
|
|
264
|
+
};
|
|
265
|
+
async function fetchAuthResponse() {
|
|
266
|
+
try {
|
|
267
|
+
const response = await auth0API.post("oauth/token", { json: payload2 }).json();
|
|
268
|
+
const authPayload = {
|
|
269
|
+
accessToken: response.access_token,
|
|
270
|
+
expiresAt: Date.now() + response.expires_in * 1e3
|
|
271
|
+
};
|
|
272
|
+
return authPayload;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (!(error instanceof HTTPError)) {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
const errorResponse = await error.response.json();
|
|
278
|
+
const isFailedAuthError = FAILED_AUTH_ERRORS.has(errorResponse.error);
|
|
279
|
+
if (isFailedAuthError) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`Login failed, please try again (${errorResponse.error_description}).`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const delay = authCtx.checkInterval * 1e3;
|
|
285
|
+
return new Promise(
|
|
286
|
+
(resolve) => setTimeout(() => resolve(fetchAuthResponse()), delay)
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return fetchAuthResponse();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/commands/login/handle-action.ts
|
|
294
|
+
async function handleAction2() {
|
|
295
|
+
const spinner = ora2("Logging in to the pd4castr API...").start();
|
|
296
|
+
try {
|
|
297
|
+
const globalConfig = await loadGlobalConfig();
|
|
298
|
+
if (isAuthed(globalConfig)) {
|
|
299
|
+
spinner.succeed("Already logged in!");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const authCtx = await startAuthFlow();
|
|
303
|
+
spinner.info(
|
|
304
|
+
`Please open the login link in your browser:
|
|
305
|
+
${authCtx.verificationURL}`
|
|
306
|
+
);
|
|
307
|
+
spinner.info(`Your login code is:
|
|
308
|
+
${authCtx.userCode}
|
|
309
|
+
`);
|
|
310
|
+
spinner.start("Waiting for login to complete...");
|
|
311
|
+
const authPayload = await completeAuthFlow(authCtx);
|
|
312
|
+
const updatedGlobalConfig = {
|
|
313
|
+
...globalConfig,
|
|
314
|
+
accessToken: authPayload.accessToken,
|
|
315
|
+
accessTokenExpiresAt: authPayload.expiresAt
|
|
316
|
+
};
|
|
317
|
+
await updateGlobalConfig(updatedGlobalConfig);
|
|
318
|
+
spinner.succeed("Successfully logged in to the pd4castr API");
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error instanceof ZodError) {
|
|
321
|
+
spinner.fail("Config validation failed");
|
|
322
|
+
logZodIssues(error);
|
|
323
|
+
} else if (error instanceof Error) {
|
|
324
|
+
spinner.fail(error.message);
|
|
325
|
+
}
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/commands/login/index.ts
|
|
331
|
+
function registerLoginCommand(program2) {
|
|
332
|
+
program2.command("login").description("Logs in to the pd4castr API.").action(handleAction2);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/commands/test/handle-action.ts
|
|
336
|
+
import ora3 from "ora";
|
|
337
|
+
import express from "express";
|
|
338
|
+
import path5 from "path";
|
|
339
|
+
import { ExecaError } from "execa";
|
|
340
|
+
import { ZodError as ZodError2 } from "zod";
|
|
341
|
+
|
|
342
|
+
// src/config/load-project-config.ts
|
|
343
|
+
import { lilconfig } from "lilconfig";
|
|
344
|
+
|
|
345
|
+
// src/schemas/project-config-schema.ts
|
|
346
|
+
import { z as z2 } from "zod";
|
|
347
|
+
var aemoDataFetcherSchema = z2.object({
|
|
348
|
+
key: z2.string(),
|
|
349
|
+
type: z2.literal("AEMO_MMS"),
|
|
350
|
+
checkInterval: z2.int().positive(),
|
|
351
|
+
config: z2.object({
|
|
352
|
+
checkQuery: z2.string(),
|
|
353
|
+
fetchQuery: z2.string()
|
|
354
|
+
})
|
|
355
|
+
});
|
|
356
|
+
var dataFetcherSchema = z2.discriminatedUnion("type", [aemoDataFetcherSchema]);
|
|
357
|
+
var modelInputSchema = z2.object({
|
|
358
|
+
key: z2.string(),
|
|
359
|
+
inputSource: z2.string(),
|
|
360
|
+
dataFetcher: z2.string(),
|
|
361
|
+
trigger: z2.string()
|
|
362
|
+
});
|
|
363
|
+
var projectConfigSchema = z2.object({
|
|
364
|
+
dataFetchers: z2.array(dataFetcherSchema).default([]),
|
|
365
|
+
modelInputs: z2.array(modelInputSchema).default([])
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// src/config/parse-project-config.ts
|
|
369
|
+
function parseProjectConfig(config) {
|
|
370
|
+
return projectConfigSchema.parse(config);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/config/load-project-config.ts
|
|
374
|
+
async function loadProjectConfig() {
|
|
375
|
+
const result = await lilconfig("pd4castr", {
|
|
376
|
+
searchPlaces: [PROJECT_CONFIG_FILE]
|
|
377
|
+
}).search();
|
|
378
|
+
if (!result?.config) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
"No config found (docs: https://github.com/pipelabs/pd4castr-model-examples/blob/main/docs/005-config.md)."
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return parseProjectConfig(result.config);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/utils/get-cwd.ts
|
|
387
|
+
function getCWD() {
|
|
388
|
+
if (!process.env.INIT_CWD) {
|
|
389
|
+
throw new Error("INIT_CWD environment variable is not set");
|
|
390
|
+
}
|
|
391
|
+
return process.env.INIT_CWD;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/commands/test/utils/build-docker-image.ts
|
|
395
|
+
import { execa } from "execa";
|
|
396
|
+
async function buildDockerImage(dockerImage) {
|
|
397
|
+
try {
|
|
398
|
+
await execa("docker", ["build", "-t", dockerImage, "."], {
|
|
399
|
+
cwd: getCWD(),
|
|
400
|
+
stdio: "pipe"
|
|
401
|
+
});
|
|
402
|
+
} catch (error) {
|
|
403
|
+
throw new Error("Failed to build docker image", { cause: error });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/commands/test/utils/create-input-handler.ts
|
|
408
|
+
import path4 from "path";
|
|
409
|
+
function createInputHandler(inputFilesPath, modelIOChecks) {
|
|
410
|
+
return (req, res) => {
|
|
411
|
+
if (!modelIOChecks.isValidInput(req.params.filename)) {
|
|
412
|
+
return res.status(404).json({ error: "File not found" });
|
|
413
|
+
}
|
|
414
|
+
modelIOChecks.trackInputHandled(req.params.filename);
|
|
415
|
+
const filePath = path4.join(getCWD(), inputFilesPath, req.params.filename);
|
|
416
|
+
return res.sendFile(filePath);
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/commands/test/utils/create-output-handler.ts
|
|
421
|
+
function createOutputHandler(modelIOChecks) {
|
|
422
|
+
return (_, res) => {
|
|
423
|
+
modelIOChecks.trackOutputHandled();
|
|
424
|
+
return res.status(200).json({ message: "Output received" });
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/utils/get-test-data-filename.ts
|
|
429
|
+
function getTestDataFilename(modelInput) {
|
|
430
|
+
return `${modelInput.inputSource}-${modelInput.key}.json`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/commands/test/utils/get-test-data-filenames.ts
|
|
434
|
+
function getTestDataFilenames(config) {
|
|
435
|
+
const testDataFilenames = config.modelInputs.map(
|
|
436
|
+
(modelInput) => getTestDataFilename(modelInput)
|
|
437
|
+
);
|
|
438
|
+
return testDataFilenames;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/commands/test/utils/model-io-checks.ts
|
|
442
|
+
var ModelIOChecks = class {
|
|
443
|
+
inputsToDownload;
|
|
444
|
+
outputUploaded;
|
|
445
|
+
constructor(data) {
|
|
446
|
+
this.inputsToDownload = {};
|
|
447
|
+
this.outputUploaded = false;
|
|
448
|
+
for (const file of data.inputFiles) {
|
|
449
|
+
this.inputsToDownload[file] = false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
trackInputHandled(filename) {
|
|
453
|
+
if (this.inputsToDownload[filename] !== void 0) {
|
|
454
|
+
this.inputsToDownload[filename] = true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
trackOutputHandled() {
|
|
458
|
+
this.outputUploaded = true;
|
|
459
|
+
}
|
|
460
|
+
isOutputHandled() {
|
|
461
|
+
return this.outputUploaded;
|
|
462
|
+
}
|
|
463
|
+
isInputHandled(filename) {
|
|
464
|
+
return this.inputsToDownload[filename];
|
|
465
|
+
}
|
|
466
|
+
isInputsHandled() {
|
|
467
|
+
return Object.values(this.inputsToDownload).every(Boolean);
|
|
468
|
+
}
|
|
469
|
+
isValidInput(filename) {
|
|
470
|
+
return this.inputsToDownload[filename] !== void 0;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/commands/test/utils/run-model-container.ts
|
|
475
|
+
import { execa as execa2 } from "execa";
|
|
476
|
+
|
|
477
|
+
// src/commands/test/utils/get-input-env.ts
|
|
478
|
+
function getInputEnv(modelInput, webserverURL) {
|
|
479
|
+
const variableName = modelInput.key.toUpperCase();
|
|
480
|
+
const filename = getTestDataFilename(modelInput);
|
|
481
|
+
const inputFileURL = `${webserverURL}/input/${filename}`;
|
|
482
|
+
return `INPUT_${variableName}_URL=${inputFileURL}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/commands/test/utils/run-model-container.ts
|
|
486
|
+
async function runModelContainer(dockerImage, config, webserverURL) {
|
|
487
|
+
const inputEnvs = config.modelInputs.map(
|
|
488
|
+
(modelInput) => getInputEnv(modelInput, webserverURL)
|
|
489
|
+
);
|
|
490
|
+
const outputEnv = `OUTPUT_URL=${webserverURL}/output`;
|
|
491
|
+
const envs = [...inputEnvs, outputEnv];
|
|
492
|
+
try {
|
|
493
|
+
const args = [
|
|
494
|
+
"run",
|
|
495
|
+
"--rm",
|
|
496
|
+
"--network=host",
|
|
497
|
+
...envs.flatMap((env) => ["--env", env]),
|
|
498
|
+
dockerImage
|
|
499
|
+
];
|
|
500
|
+
await execa2("docker", args, { cwd: getCWD(), stdio: "pipe" });
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw new Error("Failed to run model container", { cause: error });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/commands/test/utils/start-web-server.ts
|
|
507
|
+
async function startWebServer(app, port) {
|
|
508
|
+
return new Promise((resolve) => {
|
|
509
|
+
const server = app.listen(port, () => {
|
|
510
|
+
resolve(server);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/commands/test/handle-action.ts
|
|
516
|
+
async function handleAction3(options) {
|
|
517
|
+
const spinner = ora3("Starting model tests...").start();
|
|
518
|
+
const app = express();
|
|
519
|
+
const server = await startWebServer(app, options.port);
|
|
520
|
+
try {
|
|
521
|
+
const projectConfig = await loadProjectConfig();
|
|
522
|
+
spinner.start("Building docker image");
|
|
523
|
+
await buildDockerImage(options.dockerImage);
|
|
524
|
+
spinner.succeed(`Built docker image (${options.dockerImage})`);
|
|
525
|
+
const inputFiles = getTestDataFilenames(projectConfig);
|
|
526
|
+
const modelIOChecks = new ModelIOChecks({ inputFiles });
|
|
527
|
+
spinner.succeed(`Found ${inputFiles.length} input data files`);
|
|
528
|
+
const handleInput = createInputHandler(options.inputData, modelIOChecks);
|
|
529
|
+
const handleOutput = createOutputHandler(modelIOChecks);
|
|
530
|
+
app.use("/data", express.static(path5.join(getCWD(), options.inputData)));
|
|
531
|
+
app.get("/input/:filename", handleInput);
|
|
532
|
+
app.put("/output", handleOutput);
|
|
533
|
+
spinner.start("Running model container");
|
|
534
|
+
const webserverURL = `http://localhost:${options.port}`;
|
|
535
|
+
await runModelContainer(options.dockerImage, projectConfig, webserverURL);
|
|
536
|
+
spinner.succeed("Model run complete");
|
|
537
|
+
for (const file of inputFiles) {
|
|
538
|
+
const status = modelIOChecks.isInputHandled(file) ? "\u2714" : "\u2718";
|
|
539
|
+
console.log(` ${status} Input Fetched - ${file}`);
|
|
540
|
+
}
|
|
541
|
+
const outputStatus = modelIOChecks.isOutputHandled() ? "\u2714" : "\u2718";
|
|
542
|
+
console.log(` ${outputStatus} Output Uploaded`);
|
|
543
|
+
if (modelIOChecks.isInputsHandled() && modelIOChecks.isOutputHandled()) {
|
|
544
|
+
spinner.succeed("Model I/O test passed");
|
|
545
|
+
} else {
|
|
546
|
+
spinner.fail("Model I/O test failed");
|
|
547
|
+
}
|
|
548
|
+
} catch (error) {
|
|
549
|
+
if (error instanceof ZodError2) {
|
|
550
|
+
spinner.fail("Config validation failed");
|
|
551
|
+
logZodIssues(error);
|
|
552
|
+
} else if (error instanceof Error) {
|
|
553
|
+
spinner.fail(error.message);
|
|
554
|
+
}
|
|
555
|
+
if (error instanceof Error && error.cause instanceof ExecaError) {
|
|
556
|
+
console.error(error.cause.stderr);
|
|
557
|
+
}
|
|
558
|
+
process.exit(1);
|
|
559
|
+
} finally {
|
|
560
|
+
server.close();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/commands/test/index.ts
|
|
565
|
+
function registerTestCommand(program2) {
|
|
566
|
+
program2.command("test").description(
|
|
567
|
+
"Test a model by verifying input and output is handled correctly."
|
|
568
|
+
).option(
|
|
569
|
+
"-i, --input-data <path>",
|
|
570
|
+
"The path to the input data directory",
|
|
571
|
+
TEST_INPUT_DATA_DIR
|
|
572
|
+
).option(
|
|
573
|
+
"-d, --docker-image <image>",
|
|
574
|
+
"The Docker image to execute for testing",
|
|
575
|
+
// TODO: determine a default value here intelligently when we
|
|
576
|
+
// implement our image tagging strategy
|
|
577
|
+
`pd4castr/my-model:${Date.now()}`
|
|
578
|
+
).option("-p, --port <port>", "The port to run the webserver on", "9800").action(handleAction3);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/commands/fetch/handle-action.ts
|
|
582
|
+
import ora4 from "ora";
|
|
583
|
+
import { ZodError as ZodError3 } from "zod";
|
|
584
|
+
|
|
585
|
+
// src/utils/get-auth.ts
|
|
586
|
+
import invariant from "tiny-invariant";
|
|
587
|
+
async function getAuth() {
|
|
588
|
+
const config = await loadGlobalConfig();
|
|
589
|
+
if (!isAuthed(config)) {
|
|
590
|
+
throw new Error("Not authenticated. Please run `pd4castr login` to login.");
|
|
591
|
+
}
|
|
592
|
+
invariant(config.accessToken, "Access token is required");
|
|
593
|
+
invariant(config.accessTokenExpiresAt, "Access token expiry is required");
|
|
594
|
+
return {
|
|
595
|
+
accessToken: config.accessToken,
|
|
596
|
+
expiresAt: config.accessTokenExpiresAt
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/commands/fetch/utils/fetch-aemo-data.ts
|
|
601
|
+
import path6 from "path";
|
|
602
|
+
import fs4 from "fs/promises";
|
|
603
|
+
|
|
604
|
+
// src/api.ts
|
|
605
|
+
import ky2 from "ky";
|
|
606
|
+
var api = ky2.create({
|
|
607
|
+
prefixUrl: process.env.PD4CASTR_API_URL ?? API_URL
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// src/commands/fetch/utils/fetch-aemo-data.ts
|
|
611
|
+
async function fetchAEMOData(dataFetcher, authCtx) {
|
|
612
|
+
const queryPath = path6.resolve(getCWD(), dataFetcher.config.fetchQuery);
|
|
613
|
+
const querySQL = await fs4.readFile(queryPath, "utf8");
|
|
614
|
+
const headers = { Authorization: `Bearer ${authCtx.accessToken}` };
|
|
615
|
+
const payload2 = { query: querySQL, type: "AEMO_MMS" };
|
|
616
|
+
const result = await api.post("data-fetcher/run-query", { json: payload2, headers }).json();
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/commands/fetch/utils/get-fetcher.ts
|
|
621
|
+
var DATA_FETCHER_FNS = {
|
|
622
|
+
AEMO_MMS: fetchAEMOData
|
|
623
|
+
};
|
|
624
|
+
function getFetcher(type) {
|
|
625
|
+
const fetcher = DATA_FETCHER_FNS[type];
|
|
626
|
+
if (!fetcher) {
|
|
627
|
+
throw new Error(`Unsupported data fetcher type: ${type}`);
|
|
628
|
+
}
|
|
629
|
+
return fetcher;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/commands/fetch/utils/write-test-data.ts
|
|
633
|
+
import path7 from "path";
|
|
634
|
+
import fs5 from "fs/promises";
|
|
635
|
+
async function writeTestData(output, modelInput) {
|
|
636
|
+
const outputDir = path7.resolve(getCWD(), TEST_INPUT_DATA_DIR);
|
|
637
|
+
await fs5.mkdir(outputDir, { recursive: true });
|
|
638
|
+
const testDataFilename = getTestDataFilename(modelInput);
|
|
639
|
+
const outputPath = path7.resolve(outputDir, testDataFilename);
|
|
640
|
+
await fs5.writeFile(outputPath, JSON.stringify(output, void 0, 2));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/commands/fetch/handle-action.ts
|
|
644
|
+
var FETCHABLE_DATA_FETCHER_TYPES = /* @__PURE__ */ new Set(["AEMO_MMS"]);
|
|
645
|
+
async function handleAction4() {
|
|
646
|
+
const spinner = ora4("Starting data fetch...").start();
|
|
647
|
+
try {
|
|
648
|
+
const authCtx = await getAuth();
|
|
649
|
+
const projectConfig = await loadProjectConfig();
|
|
650
|
+
for (const modelInput of projectConfig.modelInputs) {
|
|
651
|
+
const dataFetcher = projectConfig.dataFetchers.find(
|
|
652
|
+
(dataFetcher2) => dataFetcher2.key === modelInput.dataFetcher
|
|
653
|
+
);
|
|
654
|
+
if (!dataFetcher) {
|
|
655
|
+
spinner.warn(
|
|
656
|
+
`\`${modelInput.key}\` - input has no data fetcher, skipping`
|
|
657
|
+
);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (!FETCHABLE_DATA_FETCHER_TYPES.has(dataFetcher.type)) {
|
|
661
|
+
spinner.warn(
|
|
662
|
+
`\`${dataFetcher.key}\` (${dataFetcher.type}) - unsupported, skipping`
|
|
663
|
+
);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
spinner.start(
|
|
667
|
+
`\`${dataFetcher.key}\` (${dataFetcher.type}) - fetching...`
|
|
668
|
+
);
|
|
669
|
+
const fetchData = getFetcher(dataFetcher.type);
|
|
670
|
+
const output = await fetchData(dataFetcher, authCtx);
|
|
671
|
+
await writeTestData(output, modelInput);
|
|
672
|
+
spinner.succeed(`\`${dataFetcher.key}\` (${dataFetcher.type}) - fetched`);
|
|
673
|
+
}
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error instanceof ZodError3) {
|
|
676
|
+
spinner.fail("Config validation failed");
|
|
677
|
+
logZodIssues(error);
|
|
678
|
+
} else if (error instanceof Error) {
|
|
679
|
+
spinner.fail(error.message);
|
|
680
|
+
}
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/commands/fetch/index.ts
|
|
686
|
+
function registerFetchCommand(program2) {
|
|
687
|
+
program2.command("fetch").description("Fetches test data from configured data fetchers.").option(
|
|
688
|
+
"-i, --input-data <path>",
|
|
689
|
+
"The path to the input data directory",
|
|
690
|
+
TEST_INPUT_DATA_DIR
|
|
691
|
+
).action(handleAction4);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/index.ts
|
|
695
|
+
registerInitCommand(program);
|
|
696
|
+
registerLoginCommand(program);
|
|
697
|
+
registerTestCommand(program);
|
|
698
|
+
registerFetchCommand(program);
|
|
699
|
+
await program.parseAsync(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pd4castr/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI tool for creating, testing, and publishing pd4castr models",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pd4castr": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"dev": "tsup --watch",
|
|
16
|
+
"cli": "node dist/index.js",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"test:coverage": "vitest run --coverage",
|
|
20
|
+
"lint": "eslint .",
|
|
21
|
+
"lint:fix": "eslint . --fix",
|
|
22
|
+
"format": "prettier --write .",
|
|
23
|
+
"format:check": "prettier --check .",
|
|
24
|
+
"type-check": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "yarn build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"cli",
|
|
29
|
+
"pd4castr"
|
|
30
|
+
],
|
|
31
|
+
"license": "UNLICENSED",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/pipelabs/pd4castr-cli.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/pipelabs/pd4castr-cli/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/pipelabs/pd4castr-cli#readme",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/express": "4.17.21",
|
|
42
|
+
"@types/node": "24.1.0",
|
|
43
|
+
"@types/supertest": "6.0.3",
|
|
44
|
+
"@typescript-eslint/eslint-plugin": "8.38.0",
|
|
45
|
+
"@typescript-eslint/parser": "8.38.0",
|
|
46
|
+
"eslint": "9.32.0",
|
|
47
|
+
"eslint-config-prettier": "10.1.8",
|
|
48
|
+
"eslint-plugin-unicorn": "60.0.0",
|
|
49
|
+
"eslint-plugin-vitest": "0.5.4",
|
|
50
|
+
"jest-extended": "6.0.0",
|
|
51
|
+
"memfs": "4.23.0",
|
|
52
|
+
"msw": "2.10.4",
|
|
53
|
+
"prettier": "3.6.2",
|
|
54
|
+
"supertest": "7.1.4",
|
|
55
|
+
"tsup": "8.5.0",
|
|
56
|
+
"typescript": "5.8.3",
|
|
57
|
+
"typescript-eslint": "8.38.0",
|
|
58
|
+
"vitest": "3.2.4"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@inquirer/prompts": "7.7.1",
|
|
62
|
+
"auth0": "4.27.0",
|
|
63
|
+
"commander": "14.0.0",
|
|
64
|
+
"execa": "9.6.0",
|
|
65
|
+
"express": "4.21.2",
|
|
66
|
+
"ky": "1.8.2",
|
|
67
|
+
"lilconfig": "3.1.3",
|
|
68
|
+
"ora": "8.2.0",
|
|
69
|
+
"tiged": "2.12.7",
|
|
70
|
+
"tiny-invariant": "1.3.3",
|
|
71
|
+
"zod": "4.0.14"
|
|
72
|
+
},
|
|
73
|
+
"engines": {
|
|
74
|
+
"node": ">=20.0.0"
|
|
75
|
+
}
|
|
76
|
+
}
|