@witchpot/gmc 0.1.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/CHANGELOG.md +19 -0
- package/README.md +118 -0
- package/bin/gmc.mjs +50 -0
- package/package.json +42 -0
- package/scripts/check-release-readiness.mjs +99 -0
- package/scripts/smoke-beta.mjs +220 -0
- package/scripts/smoke-pack.mjs +75 -0
- package/src/args.js +99 -0
- package/src/browser-login.js +255 -0
- package/src/config.js +320 -0
- package/src/constants.js +66 -0
- package/src/help.js +60 -0
- package/src/main.js +141 -0
- package/src/output.js +3 -0
- package/src/projection.js +163 -0
- package/src/requests.js +600 -0
- package/src/workflows.js +1862 -0
package/src/constants.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const EXIT_UNEXPECTED = 1;
|
|
5
|
+
export const EXIT_USAGE = 2;
|
|
6
|
+
export const EXIT_CONFIG = 3;
|
|
7
|
+
export const EXIT_API = 4;
|
|
8
|
+
|
|
9
|
+
export const CLI_PACKAGE_NAME = "@witchpot/gmc";
|
|
10
|
+
export const CLI_BETA_PACKAGE_NAME = "@witchpot/gmc";
|
|
11
|
+
export const CLI_VERSION = "0.1.0";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
|
|
14
|
+
export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
|
|
15
|
+
|
|
16
|
+
export const SUPPORTED_BETA_COMMANDS = [
|
|
17
|
+
"gmc status --json",
|
|
18
|
+
"gmc auth status --json",
|
|
19
|
+
"gmc schema --json",
|
|
20
|
+
"gmc --version",
|
|
21
|
+
"gmc update --json",
|
|
22
|
+
"gmc games search --source steam --query <text> --json",
|
|
23
|
+
"gmc games resolve --source steam --query <title> --json",
|
|
24
|
+
"gmc games detail --source steam <externalId> --json",
|
|
25
|
+
"gmc games analysis --source steam <externalId> --sections reviews,marketing --json",
|
|
26
|
+
"gmc campaigns signals --source steam <externalId> --json",
|
|
27
|
+
"gmc games topics --source steam <externalId> --json",
|
|
28
|
+
"gmc games compare --source steam <externalId> <externalId...> --focus market --json",
|
|
29
|
+
"gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
30
|
+
"gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
31
|
+
"gmc showcases search --query <text> --json",
|
|
32
|
+
"gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json"
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const PROVISIONAL_BETA_COMMANDS = [
|
|
36
|
+
"gmc reviews patterns compare --source steam --tags <tag_slug[,tag_slug...]> --segment price --json",
|
|
37
|
+
"gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
38
|
+
"gmc marketing timing --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
39
|
+
"gmc creators patterns --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
40
|
+
"gmc cohort trends --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
41
|
+
"gmc cohort pricing --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
42
|
+
"gmc cohort compare --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
43
|
+
"gmc cohort brief --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
44
|
+
"gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json"
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const COMPARE_FOCUSES = ["overview", "market", "reviews", "marketing", "audience", "showcases"];
|
|
48
|
+
export const COMPARE_FOCUS_SECTIONS = {
|
|
49
|
+
overview: [],
|
|
50
|
+
market: [],
|
|
51
|
+
reviews: ["reviews"],
|
|
52
|
+
marketing: ["marketing", "creators", "media"],
|
|
53
|
+
audience: ["reviews", "coverage", "creators"],
|
|
54
|
+
showcases: ["showcases"]
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_BENCHMARK_LIMIT = "20";
|
|
58
|
+
export const DEFAULT_CAMPAIGN_SIGNALS_LIMIT = "30";
|
|
59
|
+
export const DEFAULT_COHORT_WORKFLOW_LIMIT = "20";
|
|
60
|
+
export const DEFAULT_RESOLVE_LIMIT = "10";
|
|
61
|
+
export const DEFAULT_REVIEW_PATTERNS_LIMIT = "20";
|
|
62
|
+
export const DEFAULT_REVIEW_PATTERNS_MAX_TOPICS_PER_GAME = "10";
|
|
63
|
+
export const DEFAULT_REVIEW_PATTERNS_MIN_GAMES = "3";
|
|
64
|
+
export const DEFAULT_SHOWCASE_FIT_LIMIT = "25";
|
|
65
|
+
export const MAX_SAMPLE_PAGES = 10;
|
|
66
|
+
export const MAX_SAMPLE_SIZE = 500;
|
package/src/help.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_VERSION,
|
|
3
|
+
PROVISIONAL_BETA_COMMANDS,
|
|
4
|
+
SUPPORTED_BETA_COMMANDS
|
|
5
|
+
} from "./constants.js";
|
|
6
|
+
|
|
7
|
+
export function printHelp() {
|
|
8
|
+
process.stdout.write(`Game Market Copilot CLI
|
|
9
|
+
|
|
10
|
+
Version:
|
|
11
|
+
${CLI_VERSION}
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
gmc --version
|
|
15
|
+
gmc status --json
|
|
16
|
+
gmc auth status --json
|
|
17
|
+
gmc login
|
|
18
|
+
gmc login --no-open
|
|
19
|
+
gmc login --api-key <key>
|
|
20
|
+
gmc logout
|
|
21
|
+
gmc update --json
|
|
22
|
+
gmc config get --json
|
|
23
|
+
gmc config set apiBaseUrl <url>
|
|
24
|
+
gmc schema --json
|
|
25
|
+
gmc games search --source steam --query <text> --json
|
|
26
|
+
gmc games resolve --source steam --query <title> --json
|
|
27
|
+
gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
28
|
+
gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --json
|
|
29
|
+
gmc games detail --source steam <externalId> --json
|
|
30
|
+
gmc games detail --source steam <externalId> --fields title,snapshot.rating,snapshot.reviews --json
|
|
31
|
+
gmc games analysis --source steam <externalId> --sections reviews,marketing --json
|
|
32
|
+
gmc campaigns signals --source steam <externalId> --json
|
|
33
|
+
gmc games compare --source steam <externalId> <externalId...> --focus market --json
|
|
34
|
+
gmc games topics --source steam <externalId> --json
|
|
35
|
+
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
36
|
+
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --preset summary --json
|
|
37
|
+
gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
38
|
+
gmc marketing timing --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
39
|
+
gmc creators patterns --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
40
|
+
gmc cohort trends --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
41
|
+
gmc cohort pricing --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
42
|
+
gmc cohort compare --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
43
|
+
gmc cohort brief --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
44
|
+
gmc showcases search --query <text> --json
|
|
45
|
+
gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json
|
|
46
|
+
gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
47
|
+
|
|
48
|
+
Supported beta commands:
|
|
49
|
+
${SUPPORTED_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
|
|
50
|
+
|
|
51
|
+
Provisional beta commands:
|
|
52
|
+
${PROVISIONAL_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
|
|
53
|
+
|
|
54
|
+
Environment:
|
|
55
|
+
GMC_API_BASE_URL Defaults to https://gamemarketcopilot.com
|
|
56
|
+
GMC_API_KEY Workspace API key, sent only as an Authorization header
|
|
57
|
+
GMC_CLI_CREDENTIAL Browser-login CLI credential, sent only as an Authorization header
|
|
58
|
+
GMC_CONFIG_PATH Optional JSON config with apiBaseUrl and apiKey
|
|
59
|
+
`);
|
|
60
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import {
|
|
3
|
+
CLI_PACKAGE_NAME,
|
|
4
|
+
CLI_VERSION,
|
|
5
|
+
EXIT_API,
|
|
6
|
+
EXIT_CONFIG,
|
|
7
|
+
EXIT_UNEXPECTED,
|
|
8
|
+
EXIT_USAGE
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
import { matches, parseArgs } from "./args.js";
|
|
11
|
+
import { handleLocalCommand, loadConfig, parseBaseUrl } from "./config.js";
|
|
12
|
+
import { printHelp } from "./help.js";
|
|
13
|
+
import { writeJsonError } from "./output.js";
|
|
14
|
+
import { applyOutputProjection } from "./projection.js";
|
|
15
|
+
import { buildRequest } from "./requests.js";
|
|
16
|
+
import { executeRequestPlan } from "./workflows.js";
|
|
17
|
+
|
|
18
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
19
|
+
const { command, options, positional } = parseArgs(argv);
|
|
20
|
+
|
|
21
|
+
if (options.version || matches(command, ["version"])) {
|
|
22
|
+
if (options.json) {
|
|
23
|
+
process.stdout.write(`${JSON.stringify({
|
|
24
|
+
data: {
|
|
25
|
+
name: CLI_PACKAGE_NAME,
|
|
26
|
+
version: CLI_VERSION
|
|
27
|
+
}
|
|
28
|
+
}, null, 2)}\n`);
|
|
29
|
+
} else {
|
|
30
|
+
process.stdout.write(`gmc ${CLI_VERSION}\n`);
|
|
31
|
+
}
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.help || (command.length === 0 && positional.length === 0)) {
|
|
36
|
+
printHelp();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
const localOutput = await handleLocalCommand(command, options, positional, config);
|
|
42
|
+
if (localOutput) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify(applyOutputProjection(localOutput, {
|
|
44
|
+
fields: options.fields,
|
|
45
|
+
preset: options.preset
|
|
46
|
+
}), null, 2)}\n`);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const baseUrl = parseBaseUrl(config.apiBaseUrl);
|
|
51
|
+
const apiKey = config.apiKey;
|
|
52
|
+
const request = buildRequest(command, options, positional);
|
|
53
|
+
if (!request) {
|
|
54
|
+
writeJsonError({
|
|
55
|
+
code: "CLI_USAGE_ERROR",
|
|
56
|
+
message: `Unknown command: ${[...command, ...positional].join(" ")}`
|
|
57
|
+
});
|
|
58
|
+
process.exit(EXIT_USAGE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (request.requiresApiKey && !apiKey) {
|
|
62
|
+
writeJsonError({
|
|
63
|
+
code: "CLI_MISSING_CONFIG",
|
|
64
|
+
message: "GMC_API_KEY or GMC_CLI_CREDENTIAL is required for this command"
|
|
65
|
+
});
|
|
66
|
+
process.exit(EXIT_CONFIG);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const response = await executeRequestPlan(baseUrl, request, { apiKey });
|
|
70
|
+
const payload = matches(command, ["auth", "status"])
|
|
71
|
+
? enrichAuthStatusOutput(response, config)
|
|
72
|
+
: response;
|
|
73
|
+
const output = applyOutputProjection(payload, {
|
|
74
|
+
fields: options.fields,
|
|
75
|
+
preset: options.preset,
|
|
76
|
+
request
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function runCliEntryPoint() {
|
|
83
|
+
main().catch((error) => {
|
|
84
|
+
writeJsonError({
|
|
85
|
+
code: "CLI_UNEXPECTED_ERROR",
|
|
86
|
+
message: error instanceof Error ? error.message : "Unexpected CLI failure"
|
|
87
|
+
});
|
|
88
|
+
process.exit(EXIT_UNEXPECTED);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isCliEntryPoint(importMetaUrl, argvEntry = process.argv[1]) {
|
|
93
|
+
return Boolean(argvEntry && importMetaUrl === pathToFileURL(argvEntry).href);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function enrichAuthStatusOutput(payload, config) {
|
|
97
|
+
if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object") {
|
|
98
|
+
return payload;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...payload,
|
|
103
|
+
data: {
|
|
104
|
+
...payload.data,
|
|
105
|
+
cli: buildCliAuthStatus(config)
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildCliAuthStatus(config) {
|
|
111
|
+
const authMode = getCliCredentialAuthMode(config);
|
|
112
|
+
return {
|
|
113
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
114
|
+
configPath: config.configPath,
|
|
115
|
+
authMode,
|
|
116
|
+
credential: config.apiKey
|
|
117
|
+
? {
|
|
118
|
+
type: authMode,
|
|
119
|
+
prefix: getApiKeyPrefix(config.apiKey)
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
type: "none"
|
|
123
|
+
},
|
|
124
|
+
sources: config.sources
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCliCredentialAuthMode(config) {
|
|
129
|
+
if (!config.apiKey) return "anonymous";
|
|
130
|
+
if (config.authMode === "cli_credential" || config.fileConfig?.authMode === "cli_credential") {
|
|
131
|
+
return "cli_credential";
|
|
132
|
+
}
|
|
133
|
+
if (String(config.apiKey).startsWith("gmcc_")) return "cli_credential";
|
|
134
|
+
return "api_key";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getApiKeyPrefix(apiKey) {
|
|
138
|
+
return String(apiKey).slice(0, 12);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export { EXIT_API, EXIT_CONFIG, EXIT_UNEXPECTED, EXIT_USAGE };
|
package/src/output.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { EXIT_USAGE } from "./constants.js";
|
|
2
|
+
import { writeJsonError } from "./output.js";
|
|
3
|
+
|
|
4
|
+
export function applyFieldsProjection(payload, rawFields, label = "fields") {
|
|
5
|
+
if (rawFields == null || rawFields === "") return payload;
|
|
6
|
+
|
|
7
|
+
const fields = parseFields(rawFields);
|
|
8
|
+
if (fields.length === 0) {
|
|
9
|
+
writeJsonError({
|
|
10
|
+
code: "CLI_USAGE_ERROR",
|
|
11
|
+
message: "--fields must include at least one field"
|
|
12
|
+
});
|
|
13
|
+
process.exit(EXIT_USAGE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
|
17
|
+
return projectValue(payload, fields);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const projection = projectValueWithMatches(payload.data, fields);
|
|
21
|
+
const missingFields = label === "fields"
|
|
22
|
+
? fields.filter((field) => {
|
|
23
|
+
const pathParts = field.split(".").map((part) => part.trim()).filter(Boolean);
|
|
24
|
+
const normalizedPathParts = normalizeDataPath(pathParts);
|
|
25
|
+
return normalizedPathParts != null && !projection.matchedFields.has(field);
|
|
26
|
+
})
|
|
27
|
+
: [];
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...payload,
|
|
31
|
+
data: projection.value,
|
|
32
|
+
warnings: [
|
|
33
|
+
...(Array.isArray(payload.warnings) ? payload.warnings : []),
|
|
34
|
+
`${label} was applied client-side by the gmc CLI: ${fields.join(",")}`,
|
|
35
|
+
...(missingFields.length
|
|
36
|
+
? [`${label} were not found in data by the gmc CLI: ${missingFields.join(",")}`]
|
|
37
|
+
: [])
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function applyOutputProjection(payload, { fields, preset, request } = {}) {
|
|
43
|
+
if (fields != null && fields !== "") return applyFieldsProjection(payload, fields);
|
|
44
|
+
if (preset == null || preset === "") return payload;
|
|
45
|
+
|
|
46
|
+
const presetFields = resolvePresetFields(preset, request);
|
|
47
|
+
return applyFieldsProjection(payload, presetFields, `preset ${preset}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolvePresetFields(preset, request) {
|
|
51
|
+
if (preset !== "summary") {
|
|
52
|
+
writeJsonError({
|
|
53
|
+
code: "CLI_USAGE_ERROR",
|
|
54
|
+
message: "--preset must be one of: summary"
|
|
55
|
+
});
|
|
56
|
+
process.exit(EXIT_USAGE);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (request?.kind === "reviewPatterns") return "cohort,summary,patterns";
|
|
60
|
+
if (request?.kind === "benchmark") return "summary,sampledSummary";
|
|
61
|
+
if (request?.kind === "cohortEvidence") return "cohort,summary,patterns,segments";
|
|
62
|
+
|
|
63
|
+
writeJsonError({
|
|
64
|
+
code: "CLI_USAGE_ERROR",
|
|
65
|
+
message: "--preset summary is supported for gmc reviews patterns, gmc games benchmark, and gmc cohort/pattern workflows"
|
|
66
|
+
});
|
|
67
|
+
process.exit(EXIT_USAGE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseFields(rawFields) {
|
|
71
|
+
return String(rawFields)
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((field) => field.trim())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function projectValue(value, fields) {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return value.map((item) => projectObject(item, fields));
|
|
80
|
+
}
|
|
81
|
+
return projectObject(value, fields);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function projectValueWithMatches(value, fields) {
|
|
85
|
+
const matchedFields = new Set();
|
|
86
|
+
const projected = Array.isArray(value)
|
|
87
|
+
? value.map((item) => projectObject(item, fields, matchedFields))
|
|
88
|
+
: projectObject(value, fields, matchedFields);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
value: projected,
|
|
92
|
+
matchedFields
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function projectObject(value, fields, matchedFields) {
|
|
97
|
+
if (!value || typeof value !== "object") return value;
|
|
98
|
+
|
|
99
|
+
const projected = {};
|
|
100
|
+
for (const field of fields) {
|
|
101
|
+
const pathParts = field.split(".").map((part) => part.trim()).filter(Boolean);
|
|
102
|
+
const normalizedPathParts = normalizeDataPath(pathParts);
|
|
103
|
+
if (normalizedPathParts == null) continue;
|
|
104
|
+
if (normalizedPathParts.length === 0) return value;
|
|
105
|
+
|
|
106
|
+
const fieldValue = projectPath(value, normalizedPathParts);
|
|
107
|
+
if (fieldValue !== undefined) {
|
|
108
|
+
matchedFields?.add(field);
|
|
109
|
+
mergeProjected(projected, fieldValue);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return projected;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeDataPath(pathParts) {
|
|
116
|
+
if (pathParts.length === 0) return null;
|
|
117
|
+
if (!pathParts.every(isSafePathPart)) return null;
|
|
118
|
+
if (pathParts[0] === "data") return pathParts.slice(1);
|
|
119
|
+
if (["error", "meta", "pagination", "warnings"].includes(pathParts[0])) return null;
|
|
120
|
+
return pathParts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isSafePathPart(part) {
|
|
124
|
+
return part !== "__proto__" && part !== "prototype" && part !== "constructor";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function projectPath(value, pathParts) {
|
|
128
|
+
if (value == null) return undefined;
|
|
129
|
+
if (pathParts.length === 0) return value;
|
|
130
|
+
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
const items = value
|
|
133
|
+
.map((item) => projectPath(item, pathParts))
|
|
134
|
+
return items.some((item) => item !== undefined) ? items : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (typeof value !== "object") return undefined;
|
|
138
|
+
const [head, ...tail] = pathParts;
|
|
139
|
+
const child = projectPath(value[head], tail);
|
|
140
|
+
return child === undefined ? undefined : { [head]: child };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function mergeProjected(target, source) {
|
|
144
|
+
for (const [key, value] of Object.entries(source)) {
|
|
145
|
+
target[key] = mergeValue(target[key], value);
|
|
146
|
+
}
|
|
147
|
+
return target;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mergeValue(existing, incoming) {
|
|
151
|
+
if (incoming === undefined) return existing;
|
|
152
|
+
if (Array.isArray(existing) && Array.isArray(incoming)) {
|
|
153
|
+
return incoming.map((item, index) => mergeValue(existing[index], item));
|
|
154
|
+
}
|
|
155
|
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
156
|
+
return mergeProjected({ ...existing }, incoming);
|
|
157
|
+
}
|
|
158
|
+
return incoming;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isPlainObject(value) {
|
|
162
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
163
|
+
}
|