@rvoh/psychic 3.1.3 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +14 -2
- package/dist/cjs/src/bin/index.js +14 -1
- package/dist/cjs/src/cli/helpers/PackageManager.js +18 -14
- package/dist/cjs/src/cli/index.js +2 -0
- package/dist/cjs/src/controller/index.js +105 -19
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +4 -0
- package/dist/cjs/src/encrypt/internal-encrypt.js +2 -16
- package/dist/cjs/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js +22 -0
- package/dist/cjs/src/error/http/InternalServerError.js +4 -0
- package/dist/cjs/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js +15 -0
- package/dist/cjs/src/generate/controller.js +3 -1
- package/dist/cjs/src/generate/helpers/generateControllerContent.js +33 -3
- package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +1 -23
- package/dist/cjs/src/generate/helpers/paramSafeColumnNamesFromCliTokens.js +35 -0
- package/dist/cjs/src/generate/helpers/parseAttribute.js +35 -0
- package/dist/cjs/src/generate/helpers/reduxBindings/writeInitializer.js +3 -2
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +2 -2
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +3 -3
- package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +3 -2
- package/dist/cjs/src/generate/openapi/reduxBindings.js +2 -1
- package/dist/cjs/src/generate/openapi/zustandBindings.js +2 -1
- package/dist/cjs/src/generate/resource.js +9 -0
- package/dist/cjs/src/helpers/isSafeRedirectTarget.js +64 -0
- package/dist/cjs/src/i18n/provider.js +9 -0
- package/dist/cjs/src/psychic-app/index.js +52 -10
- package/dist/cjs/src/server/index.js +17 -2
- package/dist/cjs/src/server/params.js +38 -0
- package/dist/cjs/src/session/index.js +6 -0
- package/dist/cjs/src/watcher/Watcher.js +2 -1
- package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +14 -2
- package/dist/esm/src/bin/index.js +14 -1
- package/dist/esm/src/cli/helpers/PackageManager.js +18 -14
- package/dist/esm/src/cli/index.js +2 -0
- package/dist/esm/src/controller/index.js +105 -19
- package/dist/esm/src/devtools/helpers/launchDevServer.js +4 -0
- package/dist/esm/src/encrypt/internal-encrypt.js +2 -16
- package/dist/esm/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js +22 -0
- package/dist/esm/src/error/http/InternalServerError.js +4 -0
- package/dist/esm/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js +15 -0
- package/dist/esm/src/generate/controller.js +3 -1
- package/dist/esm/src/generate/helpers/generateControllerContent.js +33 -3
- package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +1 -23
- package/dist/esm/src/generate/helpers/paramSafeColumnNamesFromCliTokens.js +35 -0
- package/dist/esm/src/generate/helpers/parseAttribute.js +35 -0
- package/dist/esm/src/generate/helpers/reduxBindings/writeInitializer.js +3 -2
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +2 -2
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +3 -3
- package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +3 -2
- package/dist/esm/src/generate/openapi/reduxBindings.js +2 -1
- package/dist/esm/src/generate/openapi/zustandBindings.js +2 -1
- package/dist/esm/src/generate/resource.js +9 -0
- package/dist/esm/src/helpers/isSafeRedirectTarget.js +64 -0
- package/dist/esm/src/i18n/provider.js +9 -0
- package/dist/esm/src/psychic-app/index.js +52 -10
- package/dist/esm/src/server/index.js +17 -2
- package/dist/esm/src/server/params.js +38 -0
- package/dist/esm/src/session/index.js +6 -0
- package/dist/esm/src/watcher/Watcher.js +2 -1
- package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +2 -1
- package/dist/types/src/bin/index.d.ts +2 -0
- package/dist/types/src/cli/helpers/PackageManager.d.ts +7 -3
- package/dist/types/src/cli/index.d.ts +2 -0
- package/dist/types/src/controller/index.d.ts +88 -15
- package/dist/types/src/encrypt/internal-encrypt.d.ts +1 -3
- package/dist/types/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.d.ts +5 -0
- package/dist/types/src/error/http/InternalServerError.d.ts +4 -0
- package/dist/types/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.d.ts +3 -0
- package/dist/types/src/generate/controller.d.ts +3 -1
- package/dist/types/src/generate/helpers/generateControllerContent.d.ts +4 -1
- package/dist/types/src/generate/helpers/paramSafeColumnNamesFromCliTokens.d.ts +20 -0
- package/dist/types/src/generate/helpers/parseAttribute.d.ts +18 -0
- package/dist/types/src/generate/resource.d.ts +2 -0
- package/dist/types/src/helpers/isSafeRedirectTarget.d.ts +25 -0
- package/dist/types/src/i18n/provider.d.ts +9 -0
- package/dist/types/src/psychic-app/index.d.ts +40 -7
- package/dist/types/src/server/params.d.ts +46 -0
- package/package.json +5 -4
|
@@ -3,6 +3,8 @@ import * as cp from 'node:child_process';
|
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import colorize from '../../cli/helpers/colorize.js';
|
|
6
|
+
import OpenApiSpecDiffRequiresDevelopmentOrTest from '../../error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js';
|
|
7
|
+
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
6
8
|
import PsychicApp from '../../psychic-app/index.js';
|
|
7
9
|
/**
|
|
8
10
|
* Class-based OpenAPI specification diff tool
|
|
@@ -45,6 +47,8 @@ export class OpenApiSpecDiff {
|
|
|
45
47
|
* ```
|
|
46
48
|
*/
|
|
47
49
|
compare(openapiConfigs) {
|
|
50
|
+
if (!EnvInternal.isDevelopmentOrTest)
|
|
51
|
+
throw new OpenApiSpecDiffRequiresDevelopmentOrTest();
|
|
48
52
|
const results = [];
|
|
49
53
|
this.oasdiffConfig = this.getOasDiffConfig();
|
|
50
54
|
const comparing = colorize(`🔍 Comparing current OpenAPI Specs against ${this.oasdiffConfig.headBranch}...`, { color: 'cyanBright' });
|
|
@@ -115,6 +119,11 @@ export class OpenApiSpecDiff {
|
|
|
115
119
|
args.push(...flags.map(flag => `--${flag}`));
|
|
116
120
|
}
|
|
117
121
|
try {
|
|
122
|
+
// shell: true is intentional — `oasdiff` may be installed as a `.cmd` shim
|
|
123
|
+
// on Windows, which `execFile` cannot invoke directly. This helper runs
|
|
124
|
+
// only via `pnpm psy openapi:spec-diff` (developer / CI invocation), never
|
|
125
|
+
// in a deployed process; `args` are literal subcommand strings plus
|
|
126
|
+
// developer-controlled file paths, not request input. See docs/SECURITY_CVE_CHECKLIST.md R-026.
|
|
118
127
|
const output = cp.execFileSync(this.oasdiffConfig.command, args, {
|
|
119
128
|
shell: true,
|
|
120
129
|
encoding: 'utf8',
|
|
@@ -131,12 +140,15 @@ export class OpenApiSpecDiff {
|
|
|
131
140
|
/**
|
|
132
141
|
* Detects oasdiff output that indicates no reportable changes for the
|
|
133
142
|
* given subcommand. Newer oasdiff versions print informational messages
|
|
134
|
-
* like "No changes detected"
|
|
143
|
+
* like "No changes detected", "No changes to report, but the specs are
|
|
144
|
+
* different", or "No breaking changes to report, but the specs are
|
|
135
145
|
* different" to stdout rather than returning empty output.
|
|
136
146
|
*/
|
|
137
147
|
isNoChangesOutput(output) {
|
|
138
148
|
const trimmed = output.trim();
|
|
139
|
-
return trimmed === 'No changes detected' ||
|
|
149
|
+
return (trimmed === 'No changes detected' ||
|
|
150
|
+
trimmed.startsWith('No changes to report') ||
|
|
151
|
+
trimmed.startsWith('No breaking changes to report'));
|
|
140
152
|
}
|
|
141
153
|
/**
|
|
142
154
|
* Compares two OpenAPI files using oasdiff
|
|
@@ -4,6 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import ASTPsychicTypesBuilder from '../cli/helpers/ASTPsychicTypesBuilder.js';
|
|
5
5
|
import generateController from '../generate/controller.js';
|
|
6
6
|
import generateResource from '../generate/resource.js';
|
|
7
|
+
import EnvInternal from '../helpers/EnvInternal.js';
|
|
7
8
|
import isObject from '../helpers/isObject.js';
|
|
8
9
|
import OpenapiAppRenderer from '../openapi-renderer/app.js';
|
|
9
10
|
import PsychicApp from '../psychic-app/index.js';
|
|
@@ -34,6 +35,11 @@ export default class PsychicBin {
|
|
|
34
35
|
return controllerHierarchyViolations(controllersPath);
|
|
35
36
|
}
|
|
36
37
|
static async sync({ bypassDreamSync = false, schemaOnly = false, } = {}) {
|
|
38
|
+
if (!EnvInternal.isTest) {
|
|
39
|
+
DreamCLI.logger.logStartProgress(`skipping sync: auto-generated type/schema files are only built when NODE_ENV=test (current NODE_ENV: ${process.env.NODE_ENV ?? 'unset'}). Run with NODE_ENV=test to regenerate.`);
|
|
40
|
+
DreamCLI.logger.logEndProgress();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
37
43
|
if (!bypassDreamSync)
|
|
38
44
|
await DreamBin.sync(() => { }, { schemaOnly });
|
|
39
45
|
if (schemaOnly)
|
|
@@ -43,7 +49,9 @@ export default class PsychicBin {
|
|
|
43
49
|
DreamCLI.logger.logStartProgress('running post-sync operations...');
|
|
44
50
|
// call post-sync command in a separate process, so that newly-generated
|
|
45
51
|
// types can be reloaded and brought into all classes.
|
|
46
|
-
|
|
52
|
+
const { command, args } = psychicApp.psyCmd('post-sync');
|
|
53
|
+
await DreamCLI.spawn(command, {
|
|
54
|
+
args,
|
|
47
55
|
onStdout: message => {
|
|
48
56
|
DreamCLI.logger.logContinueProgress(`[post-sync]` + ' ' + message, {
|
|
49
57
|
logPrefixColor: 'greenBright',
|
|
@@ -53,6 +61,11 @@ export default class PsychicBin {
|
|
|
53
61
|
DreamCLI.logger.logEndProgress();
|
|
54
62
|
}
|
|
55
63
|
static async postSync() {
|
|
64
|
+
if (!EnvInternal.isTest) {
|
|
65
|
+
DreamCLI.logger.logStartProgress(`skipping post-sync: auto-generated type/schema files are only built when NODE_ENV=test (current NODE_ENV: ${process.env.NODE_ENV ?? 'unset'}). Run with NODE_ENV=test to regenerate.`);
|
|
66
|
+
DreamCLI.logger.logEndProgress();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
56
69
|
await this.syncOpenapiJson();
|
|
57
70
|
await this.runCliHooksAndUpdatePsychicTypesFileWithOutput();
|
|
58
71
|
await this.syncOpenapiTypescriptFiles();
|
|
@@ -4,42 +4,46 @@ export default class PackageManager {
|
|
|
4
4
|
return PsychicApp.getOrFail().packageManager;
|
|
5
5
|
}
|
|
6
6
|
static add(dependencyOrDependencies, { dev } = {}) {
|
|
7
|
-
const
|
|
8
|
-
? dependencyOrDependencies
|
|
9
|
-
: dependencyOrDependencies;
|
|
7
|
+
const list = Array.isArray(dependencyOrDependencies)
|
|
8
|
+
? dependencyOrDependencies
|
|
9
|
+
: [dependencyOrDependencies];
|
|
10
10
|
if (dev) {
|
|
11
11
|
switch (this.packageManager) {
|
|
12
12
|
case 'npm':
|
|
13
|
-
return
|
|
13
|
+
return { command: 'npm', args: ['install', '--save-dev', ...list] };
|
|
14
14
|
default:
|
|
15
|
-
return
|
|
15
|
+
return { command: this.packageManager, args: ['add', '-D', ...list] };
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
else {
|
|
19
19
|
switch (this.packageManager) {
|
|
20
20
|
case 'npm':
|
|
21
|
-
return
|
|
21
|
+
return { command: 'npm', args: ['install', ...list] };
|
|
22
22
|
default:
|
|
23
|
-
return
|
|
23
|
+
return { command: this.packageManager, args: ['add', ...list] };
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
static run(cmd) {
|
|
27
|
+
static run(cmd, args = []) {
|
|
28
28
|
switch (this.packageManager) {
|
|
29
29
|
case 'npm':
|
|
30
|
-
|
|
30
|
+
// npm requires `--` to separate npm args from script args
|
|
31
|
+
return {
|
|
32
|
+
command: 'npm',
|
|
33
|
+
args: args.length ? ['run', cmd, '--', ...args] : ['run', cmd],
|
|
34
|
+
};
|
|
31
35
|
default:
|
|
32
|
-
return
|
|
36
|
+
return { command: this.packageManager, args: [cmd, ...args] };
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
|
-
static exec(cmd) {
|
|
39
|
+
static exec(cmd, args = []) {
|
|
36
40
|
switch (this.packageManager) {
|
|
37
41
|
case 'npm':
|
|
38
|
-
return
|
|
42
|
+
return { command: 'npm', args: ['exec', '--', cmd, ...args] };
|
|
39
43
|
case 'yarn':
|
|
40
|
-
return
|
|
44
|
+
return { command: 'yarn', args: [cmd, ...args] };
|
|
41
45
|
default:
|
|
42
|
-
return
|
|
46
|
+
return { command: this.packageManager, args: ['exec', cmd, ...args] };
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -141,6 +141,8 @@ ${INDENT}Example:
|
|
|
141
141
|
${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
|
|
142
142
|
${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
|
|
143
143
|
.option('--no-soft-delete', `skip generating the @SoftDelete() decorator and the corresponding nullable \`deleted_at\` column. By default, generated models use soft-delete semantics (rows are marked deleted via \`deleted_at\` instead of being removed from the database). Pass this flag when you want records to be hard-deleted.`)
|
|
144
|
+
.option('--with-extract-params', `override the default scaffold to emit \`this.extractParams(Model, [...])\` (explicit allowlist). Only valid for admin-namespaced resources, which otherwise default to \`this.extractImplicitParams(Model)\`. Mutually exclusive with --with-extract-implicit-params.`, false)
|
|
145
|
+
.option('--with-extract-implicit-params', `override the default scaffold to emit \`this.extractImplicitParams(Model)\` (model-declared allowlist). Only useful for non-admin-namespaced resources, which otherwise default to \`this.extractParams(Model, [...])\`. Mutually exclusive with --with-extract-params.`, false)
|
|
144
146
|
.argument('<path>', `The URL path for this resource's routes, relative to the root domain. Use \`\\{\\}\` as a placeholder for a parent resource's ID parameter when nesting.
|
|
145
147
|
${INDENT}
|
|
146
148
|
${INDENT}The path determines the controller namespace hierarchy. Paths that begin with "admin" and "internal" remove the \`currentUser\` scoping of queries (\`--owning-model\` may be provided to apply query scoping). Each segment maps to a directory level in the controllers folder.
|
|
@@ -36,6 +36,7 @@ import HttpStatusUnavailableForLegalReasons from '../error/http/UnavailableForLe
|
|
|
36
36
|
import HttpStatusUnprocessableContent from '../error/http/UnprocessableContent.js';
|
|
37
37
|
import HttpStatusUnsupportedMediaType from '../error/http/UnsupportedMediaType.js';
|
|
38
38
|
import EnvInternal from '../helpers/EnvInternal.js';
|
|
39
|
+
import isSafeRedirectTarget from '../helpers/isSafeRedirectTarget.js';
|
|
39
40
|
import toJson from '../helpers/toJson.js';
|
|
40
41
|
import OpenapiPayloadValidator from '../openapi-renderer/helpers/OpenapiPayloadValidator.js';
|
|
41
42
|
import { cacheStringify, getCachedStringify } from '../openapi-renderer/helpers/stringify-cache.js';
|
|
@@ -257,11 +258,13 @@ export default class PsychicController {
|
|
|
257
258
|
const body = this.ctx.request.body ?? {};
|
|
258
259
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
259
260
|
const routeParams = this.ctx.params ?? {};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
261
|
+
// R-012 defense-in-depth: use a prototype-less object as the merge target
|
|
262
|
+
// so any `__proto__` / `constructor` / `prototype` that arrives as an OWN
|
|
263
|
+
// key via upstream parser misconfig lands here rather than mutating
|
|
264
|
+
// Object.prototype. Node's JSON.parse and qs v6.10+ already protect against
|
|
265
|
+
// the classical prototype-pollution vector; this is belt-and-suspenders.
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
267
|
+
const params = Object.assign(Object.create(null), query, body, routeParams);
|
|
265
268
|
return params;
|
|
266
269
|
}
|
|
267
270
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -400,10 +403,18 @@ export default class PsychicController {
|
|
|
400
403
|
return this._castParam(keys, nestedParams, expectedType, opts);
|
|
401
404
|
}
|
|
402
405
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
406
|
+
* @deprecated Prefer {@link extractParams} (explicit allowlist at the call
|
|
407
|
+
* site) or {@link extractImplicitParams} (same implicit-allowlist behavior,
|
|
408
|
+
* new name). Security-relevant: on models with permission-bearing fields,
|
|
409
|
+
* `paramsFor(Model)` without `only` extracts every column in
|
|
410
|
+
* `paramSafeColumnsOrFallback()` — which may be too broad unless the model
|
|
411
|
+
* declares `paramSafeColumns` explicitly. `extractParams` makes the
|
|
412
|
+
* allowlist visible to reviewers at the call site.
|
|
413
|
+
*
|
|
414
|
+
* Captures and validates parameters for the provided Dream model. Will
|
|
415
|
+
* exclude parameters that are not considered "safe" by default (based on
|
|
416
|
+
* the model's paramSafeColumns). Uses `castParam` for each parameter and
|
|
417
|
+
* will raise an exception if any parameter fails validation.
|
|
407
418
|
*
|
|
408
419
|
* @param dreamClass - The Dream model class to retrieve params for
|
|
409
420
|
* @param opts - Optional configuration object
|
|
@@ -418,17 +429,11 @@ export default class PsychicController {
|
|
|
418
429
|
* ```ts
|
|
419
430
|
* class MyController extends ApplicationController {
|
|
420
431
|
* public create() {
|
|
421
|
-
* //
|
|
422
|
-
* const
|
|
423
|
-
*
|
|
424
|
-
* // Restrict to only specific fields
|
|
425
|
-
* const restrictedParams = this.paramsFor(User, { only: ['email', 'name'] })
|
|
426
|
-
*
|
|
427
|
-
* // Include normally excluded fields
|
|
428
|
-
* const extendedParams = this.paramsFor(User, { including: ['createdAt'] })
|
|
432
|
+
* // Preferred: explicit allowlist via extractParams
|
|
433
|
+
* const safe = this.extractParams(User, ['email', 'name'])
|
|
429
434
|
*
|
|
430
|
-
* //
|
|
431
|
-
* const
|
|
435
|
+
* // Equivalent of the legacy `this.paramsFor(User)` under the new name:
|
|
436
|
+
* const viaModel = this.extractImplicitParams(User)
|
|
432
437
|
* }
|
|
433
438
|
* }
|
|
434
439
|
* ```
|
|
@@ -438,6 +443,81 @@ export default class PsychicController {
|
|
|
438
443
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
439
444
|
opts);
|
|
440
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Captures and validates parameters for the provided Dream model using an
|
|
448
|
+
* explicit, required allowlist. This is the recommended primitive for
|
|
449
|
+
* controllers handling user-editable models — the allowlist is visible at
|
|
450
|
+
* the call site, so reviewers can see exactly which fields are accepted
|
|
451
|
+
* from the request.
|
|
452
|
+
*
|
|
453
|
+
* The `allowed` array is compile-time constrained to
|
|
454
|
+
* `DreamParamSafeColumnNames<InstanceType<T>>`, so protected columns
|
|
455
|
+
* (primary key, timestamps, belongs-to foreign keys, polymorphic type
|
|
456
|
+
* fields, STI `type` column, and any column in `explicitUnsafeParamColumns`)
|
|
457
|
+
* are TypeScript errors. At runtime, the intersection against the model's
|
|
458
|
+
* `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
|
|
459
|
+
* the type system to include.
|
|
460
|
+
*
|
|
461
|
+
* Use {@link extractImplicitParams} when the model's declared
|
|
462
|
+
* `paramSafeColumns` is the canonical allowlist and duplicating it at each
|
|
463
|
+
* call site would create drift.
|
|
464
|
+
*
|
|
465
|
+
* @param dreamClass - The Dream model class to retrieve params for
|
|
466
|
+
* @param allowed - Required. The columns permitted from the request.
|
|
467
|
+
* @param opts - Optional configuration
|
|
468
|
+
* @param opts.key - Extract params from a nested key in the params object instead of root level
|
|
469
|
+
* @param opts.array - If true, expects and returns an array of param objects
|
|
470
|
+
* @returns A typed object containing the validated and casted params
|
|
471
|
+
* @throws {ParamValidationError} When any parameter validation fails
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* class BalloonsController extends ApplicationController {
|
|
476
|
+
* public create() {
|
|
477
|
+
* const params = this.extractParams(Balloon, ['name', 'color'])
|
|
478
|
+
* const balloon = await Balloon.create(params)
|
|
479
|
+
* }
|
|
480
|
+
* }
|
|
481
|
+
* ```
|
|
482
|
+
*/
|
|
483
|
+
extractParams(dreamClass, allowed, opts) {
|
|
484
|
+
const source = opts?.key ? this.params[opts.key] || {} : this.params;
|
|
485
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
486
|
+
return Params.extract(source, dreamClass, allowed, opts);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Captures and validates parameters for the provided Dream model using the
|
|
490
|
+
* model's declared `paramSafeColumns` (or, when undeclared, the framework's
|
|
491
|
+
* default-safe fallback from `paramSafeColumnsOrFallback()`).
|
|
492
|
+
*
|
|
493
|
+
* Prefer {@link extractParams} when the caller can enumerate the allowed
|
|
494
|
+
* columns at the call site — explicit allowlists are more visible to
|
|
495
|
+
* reviewers. Reach for `extractImplicitParams` when the model-level
|
|
496
|
+
* declaration is the canonical allowlist and you want to avoid duplicating
|
|
497
|
+
* it at every call site.
|
|
498
|
+
*
|
|
499
|
+
* @param dreamClass - The Dream model class to retrieve params for
|
|
500
|
+
* @param opts - Optional configuration
|
|
501
|
+
* @param opts.key - Extract params from a nested key in the params object instead of root level
|
|
502
|
+
* @param opts.array - If true, expects and returns an array of param objects
|
|
503
|
+
* @returns A typed object containing the validated and casted params
|
|
504
|
+
* @throws {ParamValidationError} When any parameter validation fails
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* ```ts
|
|
508
|
+
* class AdminBalloonsController extends ApplicationController {
|
|
509
|
+
* public create() {
|
|
510
|
+
* const params = this.extractImplicitParams(Balloon)
|
|
511
|
+
* const balloon = await Balloon.create(params)
|
|
512
|
+
* }
|
|
513
|
+
* }
|
|
514
|
+
* ```
|
|
515
|
+
*/
|
|
516
|
+
extractImplicitParams(dreamClass, opts) {
|
|
517
|
+
const source = opts?.key ? this.params[opts.key] || {} : this.params;
|
|
518
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
519
|
+
return Params.extractImplicit(source, dreamClass, opts);
|
|
520
|
+
}
|
|
441
521
|
/**
|
|
442
522
|
* Gets a cookie value from the request and casts it to the specified type.
|
|
443
523
|
*
|
|
@@ -691,6 +771,12 @@ export default class PsychicController {
|
|
|
691
771
|
koaRedirect(statusCode, newLocation) {
|
|
692
772
|
if (this._responseSent)
|
|
693
773
|
return;
|
|
774
|
+
const allowedHosts = PsychicApp.getOrFail().redirectAllowedHosts;
|
|
775
|
+
if (!isSafeRedirectTarget(newLocation, { allowedHosts })) {
|
|
776
|
+
throw new HttpStatusInternalServerError(`[psychic] refused to redirect to unsafe target: ${JSON.stringify(newLocation)}. ` +
|
|
777
|
+
`Only same-origin paths (starting with '/') or absolute URLs whose host is in ` +
|
|
778
|
+
`PsychicApp.set('redirectAllowedHosts', [...]) are permitted.`);
|
|
779
|
+
}
|
|
694
780
|
this.ctx.status = statusCode;
|
|
695
781
|
this.ctx.redirect(newLocation);
|
|
696
782
|
this._responseSent = true;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { debuglog } from 'node:util';
|
|
3
|
+
import LaunchDevServerRequiresDevelopmentOrTest from '../../error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js';
|
|
3
4
|
import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
|
|
5
|
+
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
4
6
|
import PsychicApp from '../../psychic-app/index.js';
|
|
5
7
|
import sleep from './sleep.js';
|
|
6
8
|
const devServerProcesses = {};
|
|
7
9
|
const debugEnabled = debuglog('psychic').enabled;
|
|
8
10
|
export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 20000, onStdOut } = {}) {
|
|
11
|
+
if (!EnvInternal.isDevelopmentOrTest)
|
|
12
|
+
throw new LaunchDevServerRequiresDevelopmentOrTest(cmd);
|
|
9
13
|
if (devServerProcesses[key])
|
|
10
14
|
return;
|
|
11
15
|
if (debugEnabled)
|
|
@@ -10,7 +10,7 @@ export default class InternalEncrypt {
|
|
|
10
10
|
throw new MissingCookieEncryptionOpts();
|
|
11
11
|
if (data === null || data === undefined)
|
|
12
12
|
return null;
|
|
13
|
-
return
|
|
13
|
+
return Encrypt.encrypt(data, encryptOpts.current);
|
|
14
14
|
}
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
16
|
static decryptCookie(data) {
|
|
@@ -20,21 +20,7 @@ export default class InternalEncrypt {
|
|
|
20
20
|
throw new MissingCookieEncryptionOpts();
|
|
21
21
|
if (data === null || data === undefined)
|
|
22
22
|
return null;
|
|
23
|
-
return this.doDecryption(data, encryptOpts.current, encryptOpts.legacy);
|
|
24
|
-
}
|
|
25
|
-
static doEncryption(
|
|
26
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
-
data, encryptionOpts) {
|
|
28
|
-
return Encrypt.encrypt(data, encryptionOpts);
|
|
29
|
-
}
|
|
30
|
-
static doDecryption(
|
|
31
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
-
data, encryptionOpts, legacyEncryptionOpts) {
|
|
33
23
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
34
|
-
|
|
35
|
-
if (decrypted === null && legacyEncryptionOpts)
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
37
|
-
decrypted = Encrypt.decrypt(data, legacyEncryptionOpts);
|
|
38
|
-
return decrypted;
|
|
24
|
+
return Encrypt.decrypt(data, encryptOpts.current, encryptOpts.legacy);
|
|
39
25
|
}
|
|
40
26
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default class LaunchDevServerRequiresDevelopmentOrTest extends Error {
|
|
2
|
+
cmd;
|
|
3
|
+
constructor(cmd) {
|
|
4
|
+
super();
|
|
5
|
+
this.cmd = cmd;
|
|
6
|
+
}
|
|
7
|
+
get message() {
|
|
8
|
+
return `
|
|
9
|
+
launchDevServer refused to run outside development or test
|
|
10
|
+
(NODE_ENV must be 'development' or 'test').
|
|
11
|
+
|
|
12
|
+
This helper exists to spawn a local dev server during development
|
|
13
|
+
and tests. A deployed process has no business shelling out to boot
|
|
14
|
+
another server, so refusing here turns the dev-only contract into a
|
|
15
|
+
runtime invariant. Checking \`!isDevelopmentOrTest\` (rather than
|
|
16
|
+
\`isProduction\`) means staging-style envs and any unforeseen
|
|
17
|
+
NODE_ENV value also fail closed.
|
|
18
|
+
|
|
19
|
+
cmd: ${this.cmd}
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import HttpError from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* 500 Internal Server Error. `data` is for server-side diagnostics only:
|
|
4
|
+
* it is logged but never sent to the client.
|
|
5
|
+
*/
|
|
2
6
|
export default class HttpStatusInternalServerError extends HttpError {
|
|
3
7
|
get status() {
|
|
4
8
|
return 500;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default class OpenApiSpecDiffRequiresDevelopmentOrTest extends Error {
|
|
2
|
+
get message() {
|
|
3
|
+
return `
|
|
4
|
+
OpenApiSpecDiff refused to run outside development or test
|
|
5
|
+
(NODE_ENV must be 'development' or 'test').
|
|
6
|
+
|
|
7
|
+
This helper shells out to \`oasdiff\` to compare local OpenAPI specs
|
|
8
|
+
against the head branch. It is invoked only via \`pnpm psy openapi:spec-diff\`
|
|
9
|
+
in developer or CI workflows; refusing here turns the dev-only contract
|
|
10
|
+
into a runtime invariant. Checking \`!isDevelopmentOrTest\` (rather than
|
|
11
|
+
\`isProduction\`) means staging-style envs and any unforeseen NODE_ENV
|
|
12
|
+
value also fail closed.
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -9,7 +9,7 @@ import psychicPath from '../helpers/path/psychicPath.js';
|
|
|
9
9
|
import generateControllerContent from './helpers/generateControllerContent.js';
|
|
10
10
|
import generateControllerSpecContent from './helpers/generateControllerSpecContent.js';
|
|
11
11
|
import generateResourceControllerSpecContent from './helpers/generateResourceControllerSpecContent.js';
|
|
12
|
-
export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, }) {
|
|
12
|
+
export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, paramExtractionStrategy, }) {
|
|
13
13
|
fullyQualifiedModelName = fullyQualifiedModelName
|
|
14
14
|
? DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedModelName)
|
|
15
15
|
: fullyQualifiedModelName;
|
|
@@ -70,6 +70,8 @@ export default async function generateController({ fullyQualifiedControllerName,
|
|
|
70
70
|
forAdmin,
|
|
71
71
|
forInternal,
|
|
72
72
|
singular,
|
|
73
|
+
columnsWithTypes,
|
|
74
|
+
paramExtractionStrategy,
|
|
73
75
|
}));
|
|
74
76
|
}
|
|
75
77
|
catch (error) {
|
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { DreamApp } from '@rvoh/dream';
|
|
2
2
|
import { camelize, hyphenize } from '@rvoh/dream/utils';
|
|
3
3
|
import pluralize from 'pluralize-esm';
|
|
4
|
-
|
|
4
|
+
import paramSafeColumnNamesFromCliTokens from './paramSafeColumnNamesFromCliTokens.js';
|
|
5
|
+
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], paramExtractionStrategy, }) {
|
|
6
|
+
// Admin scaffolds lean on the model's declared paramSafeColumns (implicit);
|
|
7
|
+
// non-admin scaffolds require an explicit allowlist at the call site so the
|
|
8
|
+
// permitted columns are visible to reviewers. Either default can be overridden
|
|
9
|
+
// via the `--with-extract-params` / `--with-extract-implicit-params` CLI flags,
|
|
10
|
+
// which get materialized into `paramExtractionStrategy` by the caller.
|
|
11
|
+
const resolvedExtractionStrategy = paramExtractionStrategy ?? (forAdmin ? 'implicit' : 'explicit');
|
|
12
|
+
/**
|
|
13
|
+
* Returns the `this.extract*Params(...)` expression that replaces the legacy
|
|
14
|
+
* `this.paramsFor(Model)` in the scaffold's commented hints. Does NOT include
|
|
15
|
+
* the outer closing paren that the surrounding call expects (e.g. the one
|
|
16
|
+
* closing `update(...)` or `create(...)` or `createAssociation(...)`); the
|
|
17
|
+
* caller appends that as part of its own template.
|
|
18
|
+
*/
|
|
19
|
+
const extractCallExpression = (modelClass) => {
|
|
20
|
+
if (resolvedExtractionStrategy === 'implicit') {
|
|
21
|
+
return `this.extractImplicitParams(${modelClass})`;
|
|
22
|
+
}
|
|
23
|
+
const safeColumns = paramSafeColumnNamesFromCliTokens(columnsWithTypes);
|
|
24
|
+
const serializedSafeColumns = safeColumns.length
|
|
25
|
+
? `[${safeColumns.map(name => `'${name}'`).join(', ')}]`
|
|
26
|
+
: '[]';
|
|
27
|
+
// The emitted list contains every implicitly-allowed column. When
|
|
28
|
+
// uncommenting the action body, the developer or agent is responsible
|
|
29
|
+
// for narrowing it down to only the columns this action should actually
|
|
30
|
+
// accept.
|
|
31
|
+
return `this.extractParams(${modelClass},
|
|
32
|
+
// ${serializedSafeColumns},
|
|
33
|
+
// )`;
|
|
34
|
+
};
|
|
5
35
|
fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
6
36
|
const additionalImports = [];
|
|
7
37
|
const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
@@ -42,7 +72,7 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
42
72
|
fastJsonStringify: true,
|
|
43
73
|
})
|
|
44
74
|
public async create() {
|
|
45
|
-
// let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}
|
|
75
|
+
// let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}${extractCallExpression(modelClassName)})
|
|
46
76
|
// if (${modelAttributeName}.isPersisted) ${modelAttributeName} = await ${modelAttributeName}.loadFor('${forAdmin ? 'admin' : forInternal ? 'internal' : 'default'}').execute()
|
|
47
77
|
// this.created(${modelAttributeName})
|
|
48
78
|
}`;
|
|
@@ -119,7 +149,7 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
119
149
|
})
|
|
120
150
|
public async update() {
|
|
121
151
|
// const ${modelAttributeName} = await this.${modelAttributeName}()
|
|
122
|
-
// await ${modelAttributeName}.update(
|
|
152
|
+
// await ${modelAttributeName}.update(${extractCallExpression(modelClassName)})
|
|
123
153
|
// this.noContent()
|
|
124
154
|
}`;
|
|
125
155
|
else
|
|
@@ -2,6 +2,7 @@ import { DreamApp } from '@rvoh/dream';
|
|
|
2
2
|
import { camelize, capitalize, compact, uniq } from '@rvoh/dream/utils';
|
|
3
3
|
import pluralize from 'pluralize-esm';
|
|
4
4
|
import addImportSuffix from '../../helpers/path/addImportSuffix.js';
|
|
5
|
+
import parseAttribute from './parseAttribute.js';
|
|
5
6
|
export default function generateResourceControllerSpecContent(options) {
|
|
6
7
|
const { path, pathParams } = extractPathArgsFromResourcefulPath(options.route);
|
|
7
8
|
const modelConfig = createModelConfiguration(options);
|
|
@@ -88,29 +89,6 @@ function processAttributes(columnsWithTypes, modelConfig, fullyQualifiedModelNam
|
|
|
88
89
|
}
|
|
89
90
|
return attributeData;
|
|
90
91
|
}
|
|
91
|
-
function parseAttribute(attribute) {
|
|
92
|
-
const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
|
|
93
|
-
if (!rawAttributeName || !rawAttributeType)
|
|
94
|
-
return null;
|
|
95
|
-
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
96
|
-
// Handle belongs_to relationships
|
|
97
|
-
if (sanitizedAttrType === 'belongsto') {
|
|
98
|
-
// For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
|
|
99
|
-
const attributeName = camelize(rawAttributeName.split('/').pop());
|
|
100
|
-
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
101
|
-
}
|
|
102
|
-
// Skip _type and _id columns, but not belongs_to relationships
|
|
103
|
-
if (/(_type|_id)$/.test(rawAttributeName))
|
|
104
|
-
return null;
|
|
105
|
-
const attributeName = camelize(rawAttributeName);
|
|
106
|
-
if (attributeName === 'deletedAt')
|
|
107
|
-
return null;
|
|
108
|
-
const arrayBracketRegexp = /\[\]$/;
|
|
109
|
-
const isArray = arrayBracketRegexp.test(rawAttributeType);
|
|
110
|
-
const _attributeType = rawAttributeType.replace(arrayBracketRegexp, '');
|
|
111
|
-
const attributeType = /uuid$/.test(rawAttributeName) ? 'uuid' : _attributeType;
|
|
112
|
-
return { attributeName, attributeType, isArray, enumValues };
|
|
113
|
-
}
|
|
114
92
|
function processAttributeByType({ attributeType, attributeName, isArray, enumValues, dotNotationVariable, fullyQualifiedModelName, attributeData, }) {
|
|
115
93
|
const sanitizedAttributeType = camelize(attributeType).toLowerCase();
|
|
116
94
|
switch (sanitizedAttributeType) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import parseAttribute from './parseAttribute.js';
|
|
2
|
+
/**
|
|
3
|
+
* Derive the param-safe column names from the `columnsWithTypes` CLI tokens
|
|
4
|
+
* supplied to `psy g:resource`. Filters the same set Dream's
|
|
5
|
+
* `defaultParamSafeColumns()` filters at runtime
|
|
6
|
+
* (`dream/src/Dream.ts:826-844`) — accepted deliberate duplication, since
|
|
7
|
+
* the generator runs before a live model class exists (greenfield) and
|
|
8
|
+
* therefore cannot introspect via `ModelClass.paramSafeColumnsOrFallback()`.
|
|
9
|
+
*
|
|
10
|
+
* Delegates token parsing + camelCase normalization to the shared
|
|
11
|
+
* `parseAttribute` helper so the casing emitted into generated controllers
|
|
12
|
+
* matches what every other generator emits. Then drops the additional
|
|
13
|
+
* reserved names that `parseAttribute` does not filter on its own.
|
|
14
|
+
*
|
|
15
|
+
* Omits:
|
|
16
|
+
* - Reserved camelCase names: `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`
|
|
17
|
+
* - Type annotation `:belongs_to` (foreign keys)
|
|
18
|
+
* - Name suffix `_type` (polymorphic type discriminators)
|
|
19
|
+
* - Name suffix `_id` (polymorphic foreign keys)
|
|
20
|
+
*/
|
|
21
|
+
export default function paramSafeColumnNamesFromCliTokens(tokens) {
|
|
22
|
+
const reserved = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt', 'type']);
|
|
23
|
+
const safe = [];
|
|
24
|
+
for (const token of tokens) {
|
|
25
|
+
const parsed = parseAttribute(token);
|
|
26
|
+
if (!parsed)
|
|
27
|
+
continue;
|
|
28
|
+
if (parsed.attributeType === 'belongs_to')
|
|
29
|
+
continue;
|
|
30
|
+
if (reserved.has(parsed.attributeName))
|
|
31
|
+
continue;
|
|
32
|
+
safe.push(parsed.attributeName);
|
|
33
|
+
}
|
|
34
|
+
return safe;
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { camelize } from '@rvoh/dream/utils';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a `name:type[:enumName:enumValues]` CLI token into its camelCase
|
|
4
|
+
* attribute name and metadata. Returns `null` for tokens that should be
|
|
5
|
+
* dropped from the generated artifact (polymorphic `_type`/`_id` columns,
|
|
6
|
+
* `deletedAt`, or malformed tokens missing a name or type).
|
|
7
|
+
*
|
|
8
|
+
* Centralized so generators (controller scaffolds, resource specs, the
|
|
9
|
+
* paramSafe column allowlist) share one canonical interpretation of the
|
|
10
|
+
* tokens — and one canonical casing (camelCase) for the resulting attribute
|
|
11
|
+
* name.
|
|
12
|
+
*/
|
|
13
|
+
export default function parseAttribute(attribute) {
|
|
14
|
+
const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
|
|
15
|
+
if (!rawAttributeName || !rawAttributeType)
|
|
16
|
+
return null;
|
|
17
|
+
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
18
|
+
// Handle belongs_to relationships
|
|
19
|
+
if (sanitizedAttrType === 'belongsto') {
|
|
20
|
+
// For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
|
|
21
|
+
const attributeName = camelize(rawAttributeName.split('/').pop());
|
|
22
|
+
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
23
|
+
}
|
|
24
|
+
// Skip _type and _id columns, but not belongs_to relationships
|
|
25
|
+
if (/(_type|_id)$/.test(rawAttributeName))
|
|
26
|
+
return null;
|
|
27
|
+
const attributeName = camelize(rawAttributeName);
|
|
28
|
+
if (attributeName === 'deletedAt')
|
|
29
|
+
return null;
|
|
30
|
+
const arrayBracketRegexp = /\[\]$/;
|
|
31
|
+
const isArray = arrayBracketRegexp.test(rawAttributeType);
|
|
32
|
+
const _attributeType = rawAttributeType.replace(arrayBracketRegexp, '');
|
|
33
|
+
const attributeType = /uuid$/.test(rawAttributeName) ? 'uuid' : _attributeType;
|
|
34
|
+
return { attributeName, attributeType, isArray, enumValues };
|
|
35
|
+
}
|
|
@@ -23,7 +23,7 @@ export default async function writeInitializer({ exportName }) {
|
|
|
23
23
|
await fs.mkdir(destDir, { recursive: true });
|
|
24
24
|
}
|
|
25
25
|
const filePath = path.join('.', 'src', 'conf', 'openapi', `${camelized}.openapi-codegen.json`);
|
|
26
|
-
const
|
|
26
|
+
const { command, args } = PackageManager.exec('rtk-query-codegen-openapi', [filePath]);
|
|
27
27
|
const contents = `\
|
|
28
28
|
import { DreamCLI } from '@rvoh/dream/system'
|
|
29
29
|
import { PsychicApp } from '@rvoh/psychic'
|
|
@@ -33,7 +33,8 @@ export default function initialize${pascalized}(psy: PsychicApp) {
|
|
|
33
33
|
psy.on('cli:sync', async () => {
|
|
34
34
|
if (AppEnv.isDevelopmentOrTest) {
|
|
35
35
|
await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
|
|
36
|
-
await DreamCLI.spawn('${
|
|
36
|
+
await DreamCLI.spawn('${command}', {
|
|
37
|
+
args: ${JSON.stringify(args)},
|
|
37
38
|
onStdout: message => {
|
|
38
39
|
DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
|
|
39
40
|
logPrefixColor: 'green',
|