@happyvertical/smrt-cli 0.55.10 → 0.55.11
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/AGENTS.md +11 -19
- package/README.md +4 -0
- package/agents/cookbook-apply.md +65 -0
- package/agents/kitchen.md +111 -0
- package/agents/process-fixture-isolation.md +29 -0
- package/cookbooks/bakery.cookbook.json +187 -0
- package/cookbooks/mechanic.cookbook.json +191 -0
- package/cookbooks/welder.cookbook.json +177 -0
- package/cookbooks/yoga-studio.cookbook.json +173 -0
- package/dist/{AuditLogCollection-WDHL-_AF-9sLdjeZF.js → AuditLogCollection-WDHL-_AF-ClQNu_VR.js} +1 -1
- package/dist/{OidcIdentity-BUblgQLs-QX0f44sA.js → OidcIdentity-BUblgQLs-br8RyLkA.js} +2 -2
- package/dist/{OidcIdentityCollection-ClOyaHeF-BfIHh4aA.js → OidcIdentityCollection-ClOyaHeF-BpQXB3mz.js} +1 -1
- package/dist/{ProfileAssetCollection-CRMcjLJ--Dobq-AgM.js → ProfileAssetCollection-CRMcjLJ--C5ytBKfF.js} +3 -3
- package/dist/{ProfileCollection-C4INKh7Y-DtWNBhg3.js → ProfileCollection-C4INKh7Y-CfkyJilm.js} +17 -17
- package/dist/{ProfileLinkCollection-DFfZRsgv-DNiBQtmL.js → ProfileLinkCollection-DFfZRsgv-MQ2zhD9z.js} +2 -2
- package/dist/{ProfileMetadataCollection-BIYKy4Y3-CeQxYDL4.js → ProfileMetadataCollection-BIYKy4Y3-DXu5L2yx.js} +1 -1
- package/dist/{ProfileMetafieldCollection-Buz7gzag-DP2ZYYv7.js → ProfileMetafieldCollection-Buz7gzag-C2tZc1PF.js} +1 -1
- package/dist/{ProfileRelationshipCollection-C4g_QLI4-VbnpFG2C.js → ProfileRelationshipCollection-C4g_QLI4-vTnkAIAh.js} +1 -1
- package/dist/{ProfileTypeCollection-B7kwi1mU-DWalSOyP.js → ProfileTypeCollection-B7kwi1mU-KwPGcUkI.js} +2 -2
- package/dist/apply-BNDIdixP.js +947 -0
- package/dist/cli-generator.d.ts.map +1 -1
- package/dist/commands/cookbook/apply.d.ts +102 -0
- package/dist/commands/cookbook/apply.d.ts.map +1 -0
- package/dist/commands/cookbook/recipe-index.d.ts +108 -0
- package/dist/commands/cookbook/recipe-index.d.ts.map +1 -0
- package/dist/commands/cookbook/registry.d.ts +69 -0
- package/dist/commands/cookbook/registry.d.ts.map +1 -0
- package/dist/commands/cookbook/shared.d.ts +23 -0
- package/dist/commands/cookbook/shared.d.ts.map +1 -0
- package/dist/commands/cookbook/validate.d.ts +25 -0
- package/dist/commands/cookbook/validate.d.ts.map +1 -0
- package/dist/commands/cookbook.d.ts +6 -0
- package/dist/commands/cookbook.d.ts.map +1 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/kitchen/ai.d.ts +34 -0
- package/dist/commands/kitchen/ai.d.ts.map +1 -0
- package/dist/commands/kitchen/apply.d.ts +52 -0
- package/dist/commands/kitchen/apply.d.ts.map +1 -0
- package/dist/commands/kitchen/mcp/catalog.d.ts +39 -0
- package/dist/commands/kitchen/mcp/catalog.d.ts.map +1 -0
- package/dist/commands/kitchen/mcp/server.d.ts +15 -0
- package/dist/commands/kitchen/mcp/server.d.ts.map +1 -0
- package/dist/commands/kitchen/mcp/tools.d.ts +68 -0
- package/dist/commands/kitchen/mcp/tools.d.ts.map +1 -0
- package/dist/commands/kitchen/planner.d.ts +71 -0
- package/dist/commands/kitchen/planner.d.ts.map +1 -0
- package/dist/commands/kitchen/run.d.ts +40 -0
- package/dist/commands/kitchen/run.d.ts.map +1 -0
- package/dist/commands/kitchen/server.d.ts +39 -0
- package/dist/commands/kitchen/server.d.ts.map +1 -0
- package/dist/commands/kitchen.d.ts +7 -0
- package/dist/commands/kitchen.d.ts.map +1 -0
- package/dist/{commands-n_7ZXru_.js → commands-pbRVQvix.js} +759 -9
- package/dist/{dist-BsMQ4Itc.js → dist-BsRSQ_hE.js} +16 -16
- package/dist/{dist-DscvT_q_.js → dist-CYVIKmvy.js} +4 -4
- package/dist/dist-D_WqjwkC.js +21 -0
- package/dist/{dist-gZP7Tw10.js → dist-DxkSjKQQ.js} +2 -2
- package/dist/{dist-Bw1oCty_.js → dist-ffh0LeC4.js} +3 -3
- package/dist/index.js +44 -16
- package/dist/{oidcProvisioningPrimitives-DNP9EZlU-w2kQ8oqV.js → oidcProvisioningPrimitives-DNP9EZlU-CBYwksp0.js} +3 -3
- package/dist/{resolveIdentity-CkyqvctR-Cm591gNa.js → resolveIdentity-CkyqvctR-B5btFsQd.js} +14 -14
- package/dist/server-Dp096tZj.js +898 -0
- package/package.json +13 -10
- package/dist/dist-DsZbjZ9J.js +0 -21
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { extract } from "tar";
|
|
9
|
+
import { parseCookbookText } from "@happyvertical/smrt-core/cookbook";
|
|
10
|
+
//#region src/commands/cookbook/apply.ts
|
|
11
|
+
/**
|
|
12
|
+
* `smrt cookbook apply` engine (#3748): create or update a project from a
|
|
13
|
+
* validated cookbook. Writes configuration only (package.json dependencies,
|
|
14
|
+
* `smrt.cookbook.json`, and the project name for a new project); never sample
|
|
15
|
+
* data and never anything else in the project.
|
|
16
|
+
*/
|
|
17
|
+
var COOKBOOK_CONFIG_FILE = "smrt.cookbook.json";
|
|
18
|
+
var DEP_SECTIONS = [
|
|
19
|
+
"dependencies",
|
|
20
|
+
"devDependencies",
|
|
21
|
+
"peerDependencies",
|
|
22
|
+
"optionalDependencies"
|
|
23
|
+
];
|
|
24
|
+
var ApplyError = class extends Error {};
|
|
25
|
+
function detectIndent(text) {
|
|
26
|
+
return /^( +|\t)"/m.exec(text)?.[1] ?? 2;
|
|
27
|
+
}
|
|
28
|
+
function readPackageJson(dir) {
|
|
29
|
+
const file = join(dir, "package.json");
|
|
30
|
+
if (!existsSync(file)) throw new ApplyError(`No package.json in ${dir}`);
|
|
31
|
+
const raw = readFileSync(file, "utf-8");
|
|
32
|
+
try {
|
|
33
|
+
return {
|
|
34
|
+
json: JSON.parse(raw),
|
|
35
|
+
raw
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ApplyError(`${file} is not valid JSON`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function declaredRange(json, name) {
|
|
42
|
+
for (const section of DEP_SECTIONS) {
|
|
43
|
+
const value = json[section]?.[name];
|
|
44
|
+
if (typeof value === "string") return value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Range for packages the project does not declare yet: the framework line the
|
|
49
|
+
* project already uses (smrt packages release as one fixed group), else this
|
|
50
|
+
* CLI's own release line.
|
|
51
|
+
*/
|
|
52
|
+
function chooseRange(json, cliVersion) {
|
|
53
|
+
const core = declaredRange(json, "@happyvertical/smrt-core");
|
|
54
|
+
if (core?.startsWith("workspace:")) return "workspace:*";
|
|
55
|
+
if (core && /^[\^~]?\d/.test(core)) return core;
|
|
56
|
+
return `^${cliVersion}`;
|
|
57
|
+
}
|
|
58
|
+
var slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
59
|
+
/** A valid unscoped npm package name from free text, or undefined when nothing usable remains. */
|
|
60
|
+
function npmNameFromText(value) {
|
|
61
|
+
return slug(value).slice(0, 214).replace(/-+$/, "") || void 0;
|
|
62
|
+
}
|
|
63
|
+
/** Parse `github:owner/repo#ref`, a git URL, or a local path. */
|
|
64
|
+
function parseTemplateSpec(spec) {
|
|
65
|
+
const [head, ref] = (() => {
|
|
66
|
+
const hash = spec.indexOf("#");
|
|
67
|
+
return hash === -1 ? [spec, void 0] : [spec.slice(0, hash), spec.slice(hash + 1)];
|
|
68
|
+
})();
|
|
69
|
+
const github = /^github:([\w.-]+\/[\w.-]+)$/.exec(head);
|
|
70
|
+
if (github) return {
|
|
71
|
+
kind: "git",
|
|
72
|
+
url: `https://github.com/${github[1]}.git`,
|
|
73
|
+
ref
|
|
74
|
+
};
|
|
75
|
+
if (/^(https?|ssh|git):\/\//.test(head) || /^git@/.test(head)) return {
|
|
76
|
+
kind: "git",
|
|
77
|
+
url: head,
|
|
78
|
+
ref
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
kind: "local",
|
|
82
|
+
path: resolve(spec)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function git(args, cwd) {
|
|
86
|
+
return execFileSync("git", args, {
|
|
87
|
+
cwd,
|
|
88
|
+
encoding: "utf-8",
|
|
89
|
+
stdio: [
|
|
90
|
+
"ignore",
|
|
91
|
+
"pipe",
|
|
92
|
+
"pipe"
|
|
93
|
+
],
|
|
94
|
+
env: {
|
|
95
|
+
...process.env,
|
|
96
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
97
|
+
GIT_ASKPASS: "false"
|
|
98
|
+
},
|
|
99
|
+
timeout: 12e4
|
|
100
|
+
}).trim();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Files a local git checkout contributes to a new project: everything tracked
|
|
104
|
+
* plus untracked files that are not git-ignored (`git ls-files -co
|
|
105
|
+
* --exclude-standard`). A working checkout carries git-ignored generated
|
|
106
|
+
* output (build products, a stale `src/routes/api/notes`) that must not leak
|
|
107
|
+
* into a new project, while uncommitted edits to the template still apply, so
|
|
108
|
+
* `git archive HEAD` (which would drop them) is not used. Returns undefined
|
|
109
|
+
* when `dir` is not inside a git work tree or git is unavailable.
|
|
110
|
+
*/
|
|
111
|
+
function listLocalTemplateFiles(dir) {
|
|
112
|
+
try {
|
|
113
|
+
if (git(["rev-parse", "--is-inside-work-tree"], dir) !== "true") return;
|
|
114
|
+
const out = execFileSync("git", [
|
|
115
|
+
"ls-files",
|
|
116
|
+
"-z",
|
|
117
|
+
"--cached",
|
|
118
|
+
"--others",
|
|
119
|
+
"--exclude-standard"
|
|
120
|
+
], {
|
|
121
|
+
cwd: dir,
|
|
122
|
+
encoding: "utf-8",
|
|
123
|
+
stdio: [
|
|
124
|
+
"ignore",
|
|
125
|
+
"pipe",
|
|
126
|
+
"pipe"
|
|
127
|
+
]
|
|
128
|
+
});
|
|
129
|
+
return [...new Set(out.split("\0").filter(Boolean))].filter((file) => existsSync(join(dir, file)));
|
|
130
|
+
} catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Resolve a template to a directory: a local path as-is, a git source shallow-fetched. */
|
|
135
|
+
function acquireTemplate(spec) {
|
|
136
|
+
const parsed = parseTemplateSpec(spec);
|
|
137
|
+
if (parsed.kind === "local") {
|
|
138
|
+
if (!existsSync(join(parsed.path, "package.json"))) throw new ApplyError(`Template ${parsed.path} has no package.json (pass --template <path|github:owner/repo#ref>)`);
|
|
139
|
+
return {
|
|
140
|
+
root: parsed.path,
|
|
141
|
+
files: listLocalTemplateFiles(parsed.path),
|
|
142
|
+
cleanup() {}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const root = mkdtempSync(join(tmpdir(), "smrt-template-"));
|
|
146
|
+
const cleanup = () => rmSync(root, {
|
|
147
|
+
recursive: true,
|
|
148
|
+
force: true
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
git(["init", "-q"], root);
|
|
152
|
+
if (parsed.ref?.startsWith("-") || parsed.url.startsWith("-")) throw new ApplyError(`Invalid template source ${spec}`);
|
|
153
|
+
git([
|
|
154
|
+
"fetch",
|
|
155
|
+
"-q",
|
|
156
|
+
"--depth",
|
|
157
|
+
"1",
|
|
158
|
+
"--",
|
|
159
|
+
parsed.url,
|
|
160
|
+
parsed.ref ?? "HEAD"
|
|
161
|
+
], root);
|
|
162
|
+
git([
|
|
163
|
+
"checkout",
|
|
164
|
+
"-q",
|
|
165
|
+
"FETCH_HEAD"
|
|
166
|
+
], root);
|
|
167
|
+
const revision = git(["rev-parse", "HEAD"], root);
|
|
168
|
+
rmSync(join(root, ".git"), {
|
|
169
|
+
recursive: true,
|
|
170
|
+
force: true
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
root,
|
|
174
|
+
revision,
|
|
175
|
+
cleanup
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
cleanup();
|
|
179
|
+
throw new ApplyError(`Could not fetch template ${spec}: ${error instanceof Error ? (error.stderr || error.message).trim() : String(error)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function isEmptyDir(dir) {
|
|
183
|
+
return readdirSync(dir).length === 0;
|
|
184
|
+
}
|
|
185
|
+
function renderJson(value, indent = 2) {
|
|
186
|
+
return `${JSON.stringify(value, null, indent)}\n`;
|
|
187
|
+
}
|
|
188
|
+
function packageManager(dir) {
|
|
189
|
+
if (existsSync(join(dir, "yarn.lock"))) return "yarn";
|
|
190
|
+
if (existsSync(join(dir, "package-lock.json"))) return "npm";
|
|
191
|
+
return "pnpm";
|
|
192
|
+
}
|
|
193
|
+
function nextSteps(dir, json, installed, cwd) {
|
|
194
|
+
const pm = packageManager(dir);
|
|
195
|
+
const run = pm === "npm" ? "npm run" : pm;
|
|
196
|
+
const scripts = json.scripts ?? {};
|
|
197
|
+
const steps = [];
|
|
198
|
+
if (resolve(dir) !== resolve(cwd)) {
|
|
199
|
+
const rel = relative(cwd, dir);
|
|
200
|
+
steps.push(`cd ${rel.startsWith("..") ? resolve(dir) : rel || "."}`);
|
|
201
|
+
}
|
|
202
|
+
if (!installed) steps.push(`${pm} install`);
|
|
203
|
+
steps.push(scripts["app:setup"] ? `${run} app:setup` : "npx smrt app setup", scripts["app:doctor"] ? `${run} app:doctor` : "npx smrt doctor");
|
|
204
|
+
return steps;
|
|
205
|
+
}
|
|
206
|
+
function copyTemplate(template, targetDir) {
|
|
207
|
+
const skip = (name) => name === ".git" || name === "node_modules";
|
|
208
|
+
const refuseLink = (src) => {
|
|
209
|
+
if (lstatSync(src).isSymbolicLink()) throw new ApplyError(`Template entry ${relative(template.root, src)} is a symbolic link; templates must not contain links`);
|
|
210
|
+
return true;
|
|
211
|
+
};
|
|
212
|
+
if (!template.files) {
|
|
213
|
+
cpSync(template.root, targetDir, {
|
|
214
|
+
recursive: true,
|
|
215
|
+
filter: (src) => !skip(basename(src)) && refuseLink(src)
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const file of template.files) {
|
|
220
|
+
if (file.split("/").some(skip)) continue;
|
|
221
|
+
const from = join(template.root, file);
|
|
222
|
+
refuseLink(from);
|
|
223
|
+
const to = join(targetDir, file);
|
|
224
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
225
|
+
cpSync(from, to, {
|
|
226
|
+
recursive: true,
|
|
227
|
+
filter: (src) => refuseLink(src)
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Plan and (unless dry-run) perform the apply. */
|
|
232
|
+
function applyCookbook(options) {
|
|
233
|
+
const cwd = options.cwd ?? process.cwd();
|
|
234
|
+
const spec = options.template ?? "github:happyvertical/smrt-start";
|
|
235
|
+
const configText = renderJson(options.cookbook);
|
|
236
|
+
let mode;
|
|
237
|
+
let targetDir;
|
|
238
|
+
if (options.into) {
|
|
239
|
+
mode = "update";
|
|
240
|
+
targetDir = resolve(cwd, options.dir ?? ".");
|
|
241
|
+
} else {
|
|
242
|
+
const relative = options.dir ?? (options.cookbook.name ? slug(options.cookbook.name) : "");
|
|
243
|
+
if (!relative) throw new ApplyError("Give a directory for the new project (smrt cookbook apply <file> <dir>), or use --into for the current project");
|
|
244
|
+
targetDir = resolve(cwd, relative);
|
|
245
|
+
if (existsSync(targetDir) && !isEmptyDir(targetDir)) if (existsSync(join(targetDir, "smrt.cookbook.json")) && existsSync(join(targetDir, "package.json"))) mode = "update";
|
|
246
|
+
else throw new ApplyError(`${targetDir} is not empty and holds no ${COOKBOOK_CONFIG_FILE}; pick another directory, or pass --into to apply to an existing project`);
|
|
247
|
+
else mode = "new";
|
|
248
|
+
}
|
|
249
|
+
let template;
|
|
250
|
+
try {
|
|
251
|
+
let baseJson;
|
|
252
|
+
let baseRaw;
|
|
253
|
+
if (mode === "new") {
|
|
254
|
+
template = acquireTemplate(spec);
|
|
255
|
+
({json: baseJson, raw: baseRaw} = readPackageJson(template.root));
|
|
256
|
+
} else ({json: baseJson, raw: baseRaw} = readPackageJson(targetDir));
|
|
257
|
+
const range = chooseRange(baseJson, options.cliVersion);
|
|
258
|
+
const addDependencies = {};
|
|
259
|
+
const keptDependencies = {};
|
|
260
|
+
for (const name of options.packages) {
|
|
261
|
+
const existing = declaredRange(baseJson, name);
|
|
262
|
+
if (existing !== void 0) keptDependencies[name] = existing;
|
|
263
|
+
else addDependencies[name] = range;
|
|
264
|
+
}
|
|
265
|
+
const configFile = join(targetDir, COOKBOOK_CONFIG_FILE);
|
|
266
|
+
const config = mode === "update" && existsSync(configFile) ? readFileSync(configFile, "utf-8") === configText ? "unchanged" : "update" : "create";
|
|
267
|
+
const projectName = mode === "new" ? npmNameFromText(options.cookbook.name ?? "") ?? npmNameFromText(basename(targetDir)) ?? "smrt-app" : void 0;
|
|
268
|
+
const plan = {
|
|
269
|
+
mode,
|
|
270
|
+
targetDir,
|
|
271
|
+
template: mode === "new" ? {
|
|
272
|
+
spec,
|
|
273
|
+
revision: template?.revision
|
|
274
|
+
} : void 0,
|
|
275
|
+
projectName,
|
|
276
|
+
addDependencies,
|
|
277
|
+
keptDependencies,
|
|
278
|
+
config
|
|
279
|
+
};
|
|
280
|
+
const nextJson = { ...baseJson };
|
|
281
|
+
if (projectName) nextJson.name = projectName;
|
|
282
|
+
if (Object.keys(addDependencies).length > 0) {
|
|
283
|
+
const section = "dependencies";
|
|
284
|
+
const merged = {
|
|
285
|
+
...nextJson[section] ?? {},
|
|
286
|
+
...addDependencies
|
|
287
|
+
};
|
|
288
|
+
nextJson[section] = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
289
|
+
}
|
|
290
|
+
const wantInstall = options.install !== false;
|
|
291
|
+
if (options.dryRun) return {
|
|
292
|
+
plan,
|
|
293
|
+
dryRun: true,
|
|
294
|
+
written: [],
|
|
295
|
+
installed: false,
|
|
296
|
+
nextSteps: nextSteps(targetDir, nextJson, false, cwd)
|
|
297
|
+
};
|
|
298
|
+
const written = [];
|
|
299
|
+
if (mode === "new") {
|
|
300
|
+
mkdirSync(targetDir, { recursive: true });
|
|
301
|
+
copyTemplate(template, targetDir);
|
|
302
|
+
written.push(`${targetDir} (from template)`);
|
|
303
|
+
}
|
|
304
|
+
if (mode === "new" || Object.keys(addDependencies).length > 0 || Boolean(projectName)) {
|
|
305
|
+
const file = join(targetDir, "package.json");
|
|
306
|
+
const next = renderJson(nextJson, detectIndent(baseRaw));
|
|
307
|
+
if (mode === "new" || next !== baseRaw) {
|
|
308
|
+
writeFileSync(file, next);
|
|
309
|
+
written.push(file);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (config !== "unchanged") {
|
|
313
|
+
writeFileSync(configFile, configText);
|
|
314
|
+
written.push(configFile);
|
|
315
|
+
}
|
|
316
|
+
let installed = false;
|
|
317
|
+
if (wantInstall) {
|
|
318
|
+
const pm = packageManager(targetDir);
|
|
319
|
+
if (spawnSync(pm, ["install"], {
|
|
320
|
+
cwd: targetDir,
|
|
321
|
+
stdio: options.installOutput === "stderr" ? [
|
|
322
|
+
"ignore",
|
|
323
|
+
2,
|
|
324
|
+
2
|
|
325
|
+
] : "inherit"
|
|
326
|
+
}).status !== 0) throw new ApplyError(`${pm} install failed in ${targetDir}; the cookbook is applied, fix the install error and re-run it`);
|
|
327
|
+
installed = true;
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
plan,
|
|
331
|
+
dryRun: false,
|
|
332
|
+
written,
|
|
333
|
+
installed,
|
|
334
|
+
nextSteps: nextSteps(targetDir, nextJson, installed, cwd)
|
|
335
|
+
};
|
|
336
|
+
} finally {
|
|
337
|
+
template?.cleanup();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/commands/cookbook/registry.ts
|
|
342
|
+
/**
|
|
343
|
+
* npm registry access shared by the cookbook recipe index (#3748) and
|
|
344
|
+
* `smrt kitchen` (#3750): which registry owns a package's scope (the way npm
|
|
345
|
+
* and pnpm pick it, auth token included), and fetching and extracting a
|
|
346
|
+
* package tarball without installing it.
|
|
347
|
+
*/
|
|
348
|
+
function expandEnv(value) {
|
|
349
|
+
return value.replace(/\$\{([^}]+)\}/g, (_m, name) => process.env[name] ?? "");
|
|
350
|
+
}
|
|
351
|
+
/** Parse the `.npmrc` chain: project files up the tree first, then the user's. */
|
|
352
|
+
function readNpmrcChain(dir) {
|
|
353
|
+
const files = [];
|
|
354
|
+
let current = resolve(dir);
|
|
355
|
+
while (true) {
|
|
356
|
+
files.push(join(current, ".npmrc"));
|
|
357
|
+
const parent = dirname(current);
|
|
358
|
+
if (parent === current) break;
|
|
359
|
+
current = parent;
|
|
360
|
+
}
|
|
361
|
+
files.push(join(homedir(), ".npmrc"));
|
|
362
|
+
const values = /* @__PURE__ */ new Map();
|
|
363
|
+
for (const file of files) {
|
|
364
|
+
if (!existsSync(file)) continue;
|
|
365
|
+
for (const line of readFileSync(file, "utf-8").split(/\r?\n/)) {
|
|
366
|
+
const match = /^\s*([^#;=\s][^=]*?)\s*=\s*(.*?)\s*$/.exec(line);
|
|
367
|
+
if (match && !values.has(match[1])) values.set(match[1], expandEnv(match[2]));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { values };
|
|
371
|
+
}
|
|
372
|
+
var trimSlash = (url) => url.replace(/\/+$/, "");
|
|
373
|
+
/**
|
|
374
|
+
* The registry for a package, the way npm and pnpm pick it: the scope's
|
|
375
|
+
* `@scope:registry` (`.npmrc` chain, then `npm config`), then
|
|
376
|
+
* `npm_config_registry`, then an unscoped `registry=`, then npmjs.
|
|
377
|
+
* Also returns an auth token configured for that registry's host.
|
|
378
|
+
*/
|
|
379
|
+
function registryForPackage(packageName, dir = process.cwd()) {
|
|
380
|
+
const scope = packageName.startsWith("@") ? packageName.split("/")[0] : null;
|
|
381
|
+
const rc = readNpmrcChain(dir);
|
|
382
|
+
let url = scope ? rc.values.get(`${scope}:registry`) : void 0;
|
|
383
|
+
if (scope && !url) try {
|
|
384
|
+
const out = execFileSync("npm", [
|
|
385
|
+
"config",
|
|
386
|
+
"get",
|
|
387
|
+
`${scope}:registry`
|
|
388
|
+
], {
|
|
389
|
+
encoding: "utf-8",
|
|
390
|
+
stdio: [
|
|
391
|
+
"ignore",
|
|
392
|
+
"pipe",
|
|
393
|
+
"ignore"
|
|
394
|
+
],
|
|
395
|
+
timeout: 1e4
|
|
396
|
+
}).trim();
|
|
397
|
+
if (/^https?:\/\//.test(out)) url = out;
|
|
398
|
+
} catch {}
|
|
399
|
+
url ||= process.env.npm_config_registry || rc.values.get("registry") || "https://registry.npmjs.org/";
|
|
400
|
+
url = trimSlash(url);
|
|
401
|
+
const hostPath = url.replace(/^https?:/, "");
|
|
402
|
+
const token = rc.values.get(`${hostPath}/:_authToken`) ?? rc.values.get(`${hostPath.replace(/\/[^/]*$/, "")}/:_authToken`);
|
|
403
|
+
return {
|
|
404
|
+
url,
|
|
405
|
+
token: token || void 0
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** A package this CLI may fetch from a registry without installing it. */
|
|
409
|
+
var SMRT_PACKAGE_NAME = /^@happyvertical\/smrt-[a-z0-9][a-z0-9-]*$/;
|
|
410
|
+
/** A registry-supplied version is a path segment in the planner cache. */
|
|
411
|
+
var SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
412
|
+
/**
|
|
413
|
+
* Ask the registry that owns a package's scope which version to use, without
|
|
414
|
+
* downloading anything. Tries `versionHint` first (when given), then `latest`.
|
|
415
|
+
* Returns null when the package or version is unavailable.
|
|
416
|
+
*/
|
|
417
|
+
async function lookupPackage(packageName, options = {}) {
|
|
418
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
419
|
+
const resolved = options.registryUrl ? {
|
|
420
|
+
url: trimSlash(options.registryUrl),
|
|
421
|
+
token: void 0
|
|
422
|
+
} : registryForPackage(packageName, options.dir);
|
|
423
|
+
const base = resolved.url;
|
|
424
|
+
const headers = resolved.token ? { authorization: `Bearer ${resolved.token}` } : {};
|
|
425
|
+
const encoded = packageName.replace("/", "%2F");
|
|
426
|
+
for (const version of [options.versionHint, "latest"]) {
|
|
427
|
+
if (!version) continue;
|
|
428
|
+
try {
|
|
429
|
+
const response = await fetchImpl(`${base}/${encoded}/${version}`, { headers });
|
|
430
|
+
if (!response.ok) continue;
|
|
431
|
+
const meta = await response.json();
|
|
432
|
+
const tarballUrl = meta.dist?.tarball;
|
|
433
|
+
if (!tarballUrl) return null;
|
|
434
|
+
if (meta.version !== void 0 && !SEMVER.test(meta.version)) return null;
|
|
435
|
+
let tarball;
|
|
436
|
+
let registry;
|
|
437
|
+
try {
|
|
438
|
+
tarball = new URL(tarballUrl);
|
|
439
|
+
registry = new URL(base);
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
const sameOrigin = tarball.origin === registry.origin;
|
|
444
|
+
if (tarball.protocol !== "https:" && !sameOrigin) return null;
|
|
445
|
+
return {
|
|
446
|
+
version: meta.version,
|
|
447
|
+
registry: base,
|
|
448
|
+
tarballUrl,
|
|
449
|
+
headers: sameOrigin ? headers : {},
|
|
450
|
+
integrity: meta.dist?.integrity
|
|
451
|
+
};
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
/** Download the tarball a {@link lookupPackage} found. Null when unavailable. */
|
|
459
|
+
async function downloadPackage(found, fetchImpl = fetch) {
|
|
460
|
+
try {
|
|
461
|
+
const response = await fetchImpl(found.tarballUrl, { headers: found.headers });
|
|
462
|
+
if (!response.ok) return null;
|
|
463
|
+
const data = await response.arrayBuffer();
|
|
464
|
+
if (data.byteLength > 314572800) return null;
|
|
465
|
+
if (found.integrity?.startsWith("sha512-")) {
|
|
466
|
+
if (`sha512-${createHash("sha512").update(Buffer.from(data)).digest("base64")}` !== found.integrity) return null;
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
version: found.version,
|
|
470
|
+
registry: found.registry,
|
|
471
|
+
data
|
|
472
|
+
};
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Look a package up and download its tarball. Null when unavailable. */
|
|
478
|
+
async function fetchPackageTarball(packageName, options = {}) {
|
|
479
|
+
const found = await lookupPackage(packageName, options);
|
|
480
|
+
return found ? downloadPackage(found, options.fetchImpl) : null;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Extract a tarball into `cwd` (entries keep their `package/` prefix). A
|
|
484
|
+
* `filter` limits which entries are written.
|
|
485
|
+
*/
|
|
486
|
+
async function extractTarball(data, cwd, filter) {
|
|
487
|
+
await new Promise((done, fail) => {
|
|
488
|
+
const sink = extract({
|
|
489
|
+
cwd,
|
|
490
|
+
...filter ? { filter } : {}
|
|
491
|
+
});
|
|
492
|
+
sink.on("close", () => done());
|
|
493
|
+
sink.on("error", fail);
|
|
494
|
+
Readable.from(Buffer.from(data)).pipe(sink);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region src/commands/cookbook/recipe-index.ts
|
|
499
|
+
/**
|
|
500
|
+
* Recipe index for `smrt cookbook validate|apply` (#3748).
|
|
501
|
+
*
|
|
502
|
+
* Source of truth: each package's published manifest (its
|
|
503
|
+
* `./manifest` export; `dist/manifest.json` or `dist/lib/manifest.json`), which carries `recipes[]` and the `objects` the
|
|
504
|
+
* cookbook's policies and features point at. Manifests are read from, in
|
|
505
|
+
* order: explicit `--manifests` paths, the SMRT workspace around the working
|
|
506
|
+
* directory, the project's `node_modules`, and finally (opt-out with
|
|
507
|
+
* `--no-registry`) the npm registry for packages that are not installed yet.
|
|
508
|
+
*/
|
|
509
|
+
var SMRT_SCOPE = "@happyvertical";
|
|
510
|
+
var PACKAGE_PREFIX = "smrt-";
|
|
511
|
+
function createRecipeIndex() {
|
|
512
|
+
return {
|
|
513
|
+
recipes: /* @__PURE__ */ new Map(),
|
|
514
|
+
packages: /* @__PURE__ */ new Map(),
|
|
515
|
+
missing: /* @__PURE__ */ new Set()
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/** Add one parsed manifest to the index. Returns false when it names no package. */
|
|
519
|
+
function addManifest(index, manifest, source) {
|
|
520
|
+
const packageName = manifest.packageName;
|
|
521
|
+
if (!packageName || index.packages.has(packageName)) return Boolean(packageName);
|
|
522
|
+
const objects = /* @__PURE__ */ new Map();
|
|
523
|
+
for (const [name, object] of Object.entries(manifest.objects ?? {})) {
|
|
524
|
+
const fields = object?.fields;
|
|
525
|
+
objects.set(name, new Set(Array.isArray(fields) ? fields.map((f) => typeof f === "string" ? f : String(f.name)) : Object.keys(fields ?? {})));
|
|
526
|
+
}
|
|
527
|
+
index.packages.set(packageName, {
|
|
528
|
+
packageName,
|
|
529
|
+
packageVersion: manifest.packageVersion,
|
|
530
|
+
source,
|
|
531
|
+
objects,
|
|
532
|
+
manifest
|
|
533
|
+
});
|
|
534
|
+
for (const recipe of manifest.recipes ?? []) {
|
|
535
|
+
if (!recipe.id || index.recipes.has(recipe.id)) continue;
|
|
536
|
+
index.recipes.set(recipe.id, {
|
|
537
|
+
id: recipe.id,
|
|
538
|
+
packageName,
|
|
539
|
+
packageVersion: manifest.packageVersion,
|
|
540
|
+
requires: recipe.requires ?? [],
|
|
541
|
+
requiresAny: recipe.requiresAny ?? [],
|
|
542
|
+
models: recipe.models ?? [],
|
|
543
|
+
hasOptions: Boolean(recipe.options && Object.keys(recipe.options).length > 0),
|
|
544
|
+
hasDemoSeed: recipe.demoSeed !== void 0
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
var MANIFEST_FALLBACKS = [
|
|
550
|
+
"dist/manifest.json",
|
|
551
|
+
"dist/lib/manifest.json",
|
|
552
|
+
"manifest.json"
|
|
553
|
+
];
|
|
554
|
+
function exportTarget(value) {
|
|
555
|
+
if (typeof value === "string") return value;
|
|
556
|
+
if (value && typeof value === "object") {
|
|
557
|
+
const record = value;
|
|
558
|
+
return exportTarget(record.default ?? record.import ?? record.require);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* A package directory's manifest file: its `exports['./manifest']` (or
|
|
563
|
+
* `./manifest.json`) target, else the known locations. SvelteKit-library
|
|
564
|
+
* packages publish at `dist/lib/manifest.json`, not `dist/manifest.json`.
|
|
565
|
+
*/
|
|
566
|
+
function manifestPathForPackageDir(dir) {
|
|
567
|
+
try {
|
|
568
|
+
const exports = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")).exports;
|
|
569
|
+
const target = exportTarget(exports?.["./manifest"]) ?? exportTarget(exports?.["./manifest.json"]);
|
|
570
|
+
if (target?.endsWith(".json")) {
|
|
571
|
+
const file = resolve(dir, target);
|
|
572
|
+
if (existsSync(file)) return file;
|
|
573
|
+
}
|
|
574
|
+
} catch {}
|
|
575
|
+
for (const relative of MANIFEST_FALLBACKS) {
|
|
576
|
+
const file = join(dir, relative);
|
|
577
|
+
if (existsSync(file)) return file;
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
function readManifestFile(index, file, warn) {
|
|
582
|
+
try {
|
|
583
|
+
addManifest(index, JSON.parse(readFileSync(file, "utf-8")), file);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
warn(`Skipped unreadable manifest ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Read manifests from a path: a manifest file, a directory of `*.json`
|
|
590
|
+
* manifests, or a directory of package directories (`<pkg>/dist/manifest.json`
|
|
591
|
+
* or `<pkg>/manifest.json`).
|
|
592
|
+
*/
|
|
593
|
+
function loadManifestPath(index, path, warn = () => {}) {
|
|
594
|
+
const target = resolve(path);
|
|
595
|
+
if (!existsSync(target)) {
|
|
596
|
+
warn(`Manifest path not found: ${target}`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (statSync(target).isFile()) {
|
|
600
|
+
readManifestFile(index, target, warn);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const own = manifestPathForPackageDir(target);
|
|
604
|
+
if (own) {
|
|
605
|
+
readManifestFile(index, own, warn);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
for (const entry of readdirSync(target).sort()) {
|
|
609
|
+
const child = join(target, entry);
|
|
610
|
+
if (entry.endsWith(".json")) readManifestFile(index, child, warn);
|
|
611
|
+
else if (statSync(child).isDirectory()) {
|
|
612
|
+
const file = manifestPathForPackageDir(child);
|
|
613
|
+
if (file) readManifestFile(index, file, warn);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Walk up from `start` for the SMRT monorepo root (`packages/*` + workspace file). */
|
|
618
|
+
function findSmrtWorkspace(start) {
|
|
619
|
+
let current = resolve(start);
|
|
620
|
+
while (true) {
|
|
621
|
+
if (existsSync(join(current, "pnpm-workspace.yaml")) && existsSync(join(current, "packages", "core", "package.json"))) return current;
|
|
622
|
+
const parent = dirname(current);
|
|
623
|
+
if (parent === current) return null;
|
|
624
|
+
current = parent;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Load workspace and installed manifests visible from `dir`. */
|
|
628
|
+
function loadLocalManifests(index, dir, warn = () => {}) {
|
|
629
|
+
const workspace = findSmrtWorkspace(dir);
|
|
630
|
+
if (workspace) {
|
|
631
|
+
const packagesDir = join(workspace, "packages");
|
|
632
|
+
for (const entry of readdirSync(packagesDir).sort()) {
|
|
633
|
+
const manifest = manifestPathForPackageDir(join(packagesDir, entry));
|
|
634
|
+
if (manifest) readManifestFile(index, manifest, warn);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
let current = resolve(dir);
|
|
638
|
+
while (true) {
|
|
639
|
+
const scope = join(current, "node_modules", SMRT_SCOPE);
|
|
640
|
+
if (existsSync(scope)) for (const entry of readdirSync(scope).sort()) {
|
|
641
|
+
if (!entry.startsWith(PACKAGE_PREFIX)) continue;
|
|
642
|
+
const manifest = manifestPathForPackageDir(join(scope, entry));
|
|
643
|
+
if (manifest) readManifestFile(index, manifest, warn);
|
|
644
|
+
}
|
|
645
|
+
const parent = dirname(current);
|
|
646
|
+
if (parent === current) break;
|
|
647
|
+
current = parent;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/** Recipe id prefixes whose package name differs from `smrt-<prefix>`. */
|
|
651
|
+
var PREFIX_PACKAGE_ALIASES = { integrations: "webhooks" };
|
|
652
|
+
/** The package that conventionally owns a recipe id (`commerce.sales` -> smrt-commerce). */
|
|
653
|
+
function candidatePackageForRecipe(id) {
|
|
654
|
+
const prefix = id.split(".")[0] ?? id;
|
|
655
|
+
return `${SMRT_SCOPE}/${PACKAGE_PREFIX}${PREFIX_PACKAGE_ALIASES[prefix] ?? prefix}`;
|
|
656
|
+
}
|
|
657
|
+
/** The package a qualified object (`@scope/pkg:Class`) belongs to, if qualified. */
|
|
658
|
+
function packageOfQualifiedName(ref) {
|
|
659
|
+
const at = ref.lastIndexOf(":");
|
|
660
|
+
if (!ref.startsWith("@") || at <= 0) return null;
|
|
661
|
+
return ref.slice(0, at);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Fetch a package's manifest from the registry that owns its scope, without
|
|
665
|
+
* installing it. Returns false when the package or its manifest is missing.
|
|
666
|
+
*/
|
|
667
|
+
async function loadRegistryManifest(index, packageName, options = {}) {
|
|
668
|
+
if (!SMRT_PACKAGE_NAME.test(packageName)) return false;
|
|
669
|
+
const tarball = await fetchPackageTarball(packageName, options);
|
|
670
|
+
if (!tarball) return false;
|
|
671
|
+
const work = mkdtempSync(join(tmpdir(), "smrt-cookbook-"));
|
|
672
|
+
try {
|
|
673
|
+
await extractTarball(tarball.data, work, (path) => path === "package/package.json" || path.endsWith("manifest.json"));
|
|
674
|
+
const file = manifestPathForPackageDir(join(work, "package"));
|
|
675
|
+
if (!file) return false;
|
|
676
|
+
const manifest = JSON.parse(readFileSync(file, "utf-8"));
|
|
677
|
+
if (manifest.packageName !== packageName) return false;
|
|
678
|
+
return addManifest(index, manifest, `${tarball.registry}/${packageName.replace("/", "%2F")}@${tarball.version ?? "latest"}`);
|
|
679
|
+
} finally {
|
|
680
|
+
rmSync(work, {
|
|
681
|
+
recursive: true,
|
|
682
|
+
force: true
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** What a cookbook needs resolved, for the registry-fill loop. */
|
|
687
|
+
function neededPackages(index, cookbook) {
|
|
688
|
+
const packages = /* @__PURE__ */ new Set();
|
|
689
|
+
const queue = [...cookbook.recipes ?? []];
|
|
690
|
+
const seen = /* @__PURE__ */ new Set();
|
|
691
|
+
while (queue.length) {
|
|
692
|
+
const id = queue.shift();
|
|
693
|
+
if (seen.has(id)) continue;
|
|
694
|
+
seen.add(id);
|
|
695
|
+
const entry = index.recipes.get(id);
|
|
696
|
+
if (!entry) {
|
|
697
|
+
packages.add(candidatePackageForRecipe(id));
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
queue.push(...entry.requires, ...entry.requiresAny.flat());
|
|
701
|
+
}
|
|
702
|
+
for (const ref of [...cookbook.features ?? [], ...(cookbook.policies ?? []).map((p) => p.objectRef ?? "")]) {
|
|
703
|
+
const pkg = packageOfQualifiedName(ref);
|
|
704
|
+
if (pkg) packages.add(pkg);
|
|
705
|
+
}
|
|
706
|
+
return [...packages].filter((name) => !index.packages.has(name) && !index.missing.has(name));
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Build the index a cookbook needs: local sources first, then the registry
|
|
710
|
+
* for packages still unresolved, repeating until no new package appears.
|
|
711
|
+
*/
|
|
712
|
+
async function resolveRecipeIndex(cookbook, options) {
|
|
713
|
+
const warn = options.warn ?? (() => {});
|
|
714
|
+
const index = createRecipeIndex();
|
|
715
|
+
for (const path of options.manifests ?? []) loadManifestPath(index, path, warn);
|
|
716
|
+
loadLocalManifests(index, options.dir, warn);
|
|
717
|
+
if (options.registry === false) return index;
|
|
718
|
+
for (let round = 0; round < 8; round += 1) {
|
|
719
|
+
const needed = neededPackages(index, cookbook);
|
|
720
|
+
if (needed.length === 0) break;
|
|
721
|
+
for (const name of needed) if (!await loadRegistryManifest(index, name, {
|
|
722
|
+
dir: options.dir,
|
|
723
|
+
...options.registryOptions
|
|
724
|
+
})) index.missing.add(name);
|
|
725
|
+
}
|
|
726
|
+
return index;
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/commands/cookbook/validate.ts
|
|
730
|
+
/**
|
|
731
|
+
* Cookbook validation against the recipe index (#3748): the structural check
|
|
732
|
+
* lives in core (`validateCookbook`); this adds what needs manifests.
|
|
733
|
+
*/
|
|
734
|
+
var unresolvedHint = (index, packageName) => index.missing.has(packageName) ? ` (package ${packageName} could not be found; install it, pass --manifests, or check the registry)` : "";
|
|
735
|
+
/** Validate cookbook text structurally and against the recipe index. */
|
|
736
|
+
function validateCookbookText(text, index) {
|
|
737
|
+
const structural = parseCookbookText(text);
|
|
738
|
+
if (!structural.ok) return {
|
|
739
|
+
ok: false,
|
|
740
|
+
errors: structural.errors,
|
|
741
|
+
warnings: [],
|
|
742
|
+
packages: []
|
|
743
|
+
};
|
|
744
|
+
const cookbook = structural.cookbook;
|
|
745
|
+
const errors = [];
|
|
746
|
+
const warnings = [...structural.warnings];
|
|
747
|
+
const packages = /* @__PURE__ */ new Set();
|
|
748
|
+
const selected = new Set(cookbook.recipes);
|
|
749
|
+
for (const id of cookbook.recipes) {
|
|
750
|
+
const entry = index.recipes.get(id);
|
|
751
|
+
if (!entry) {
|
|
752
|
+
errors.push(`recipe "${id}" does not exist${unresolvedHint(index, candidatePackageForRecipe(id))}`);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
packages.add(entry.packageName);
|
|
756
|
+
for (const required of entry.requires) if (!selected.has(required)) errors.push(`recipe "${id}" requires "${required}", which is missing`);
|
|
757
|
+
for (const group of entry.requiresAny) if (!group.some((alt) => selected.has(alt))) errors.push(`recipe "${id}" requires one of ${group.map((g) => `"${g}"`).join(", ")}, and none is present`);
|
|
758
|
+
}
|
|
759
|
+
const checkObject = (ref, at) => {
|
|
760
|
+
const pkg = packageOfQualifiedName(ref);
|
|
761
|
+
if (!pkg) {
|
|
762
|
+
errors.push(`${at}: "${ref}" is not a qualified name (@scope/pkg:Class)`);
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
const info = index.packages.get(pkg);
|
|
766
|
+
if (!info) {
|
|
767
|
+
errors.push(`${at}: package ${pkg} for "${ref}" is not resolvable${unresolvedHint(index, pkg)}`);
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
packages.add(pkg);
|
|
771
|
+
const fields = info.objects.get(ref);
|
|
772
|
+
if (!fields) {
|
|
773
|
+
errors.push(`${at}: object "${ref}" does not exist in ${pkg}`);
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
return fields;
|
|
777
|
+
};
|
|
778
|
+
(cookbook.features ?? []).forEach((ref, i) => {
|
|
779
|
+
checkObject(ref, `features[${i}]`);
|
|
780
|
+
});
|
|
781
|
+
(cookbook.policies ?? []).forEach((policy, i) => {
|
|
782
|
+
const at = `policies[${i}]`;
|
|
783
|
+
const fields = checkObject(policy.objectRef, at);
|
|
784
|
+
if (fields && !fields.has(policy.fieldName)) errors.push(`${at}: field "${policy.fieldName}" does not exist on ${policy.objectRef}`);
|
|
785
|
+
});
|
|
786
|
+
return {
|
|
787
|
+
ok: errors.length === 0,
|
|
788
|
+
errors,
|
|
789
|
+
warnings,
|
|
790
|
+
cookbook: errors.length === 0 ? cookbook : void 0,
|
|
791
|
+
packages: [...packages].sort()
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/** Cookbook shape needed to plan resolution before validation. */
|
|
795
|
+
function parseForResolution(text) {
|
|
796
|
+
try {
|
|
797
|
+
const raw = JSON.parse(text);
|
|
798
|
+
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
799
|
+
return {
|
|
800
|
+
recipes: strings(raw.recipes),
|
|
801
|
+
features: strings(raw.features),
|
|
802
|
+
policies: Array.isArray(raw.policies) ? raw.policies.filter((p) => typeof p === "object" && p !== null) : []
|
|
803
|
+
};
|
|
804
|
+
} catch {
|
|
805
|
+
return {
|
|
806
|
+
recipes: [],
|
|
807
|
+
features: [],
|
|
808
|
+
policies: []
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/commands/cookbook/shared.ts
|
|
814
|
+
/**
|
|
815
|
+
* Pieces of `smrt cookbook` that `smrt kitchen` (#3750) reuses: reading a
|
|
816
|
+
* cookbook source, validating it against the recipe index, and printing an
|
|
817
|
+
* apply result.
|
|
818
|
+
*/
|
|
819
|
+
/** Version of this CLI: the last-resort range for packages a cookbook adds. */
|
|
820
|
+
function cliVersion() {
|
|
821
|
+
let current = dirname(fileURLToPath(import.meta.url));
|
|
822
|
+
while (true) {
|
|
823
|
+
try {
|
|
824
|
+
const pkg = JSON.parse(readFileSync(join(current, "package.json"), "utf-8"));
|
|
825
|
+
if (pkg.name === "@happyvertical/smrt-cli" && pkg.version) return pkg.version;
|
|
826
|
+
} catch {}
|
|
827
|
+
const parent = dirname(current);
|
|
828
|
+
if (parent === current) return "0.0.0";
|
|
829
|
+
current = parent;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/** A cookbook is a small JSON file; the kitchen accepts at most 2 MB too. */
|
|
833
|
+
var MAX_COOKBOOK_CHARS = 2 * 1024 * 1024;
|
|
834
|
+
/** Cookbook text from a file path or an http(s) URL. */
|
|
835
|
+
async function readCookbookSource(source) {
|
|
836
|
+
if (/^https?:\/\//.test(source)) {
|
|
837
|
+
const response = await fetch(source);
|
|
838
|
+
if (!response.ok) throw new ApplyError(`Could not fetch ${source}: HTTP ${response.status}`);
|
|
839
|
+
const text = await response.text();
|
|
840
|
+
if (text.length > MAX_COOKBOOK_CHARS) throw new ApplyError(`${source} is larger than a cookbook can be.`);
|
|
841
|
+
return text;
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
return readFileSync(resolve(source), "utf-8");
|
|
845
|
+
} catch {
|
|
846
|
+
throw new ApplyError(`Could not read cookbook file ${resolve(source)}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
var asList = (value) => value === void 0 ? [] : Array.isArray(value) ? value : [value];
|
|
850
|
+
/** Validate cookbook text structurally and against the resolved recipe index. */
|
|
851
|
+
async function validateCookbookSource(text, options) {
|
|
852
|
+
const warnings = [];
|
|
853
|
+
const report = validateCookbookText(text, await resolveRecipeIndex(parseForResolution(text), {
|
|
854
|
+
dir: options.dir,
|
|
855
|
+
manifests: options.manifests ?? [],
|
|
856
|
+
registry: options.registry !== false,
|
|
857
|
+
warn: (message) => warnings.push(message)
|
|
858
|
+
}));
|
|
859
|
+
report.warnings.push(...warnings);
|
|
860
|
+
return report;
|
|
861
|
+
}
|
|
862
|
+
/** Print what an apply did (or, for a dry run, would do) and the next steps. */
|
|
863
|
+
function printApplyResult(result, warnings, log = console.log, warn = console.warn) {
|
|
864
|
+
for (const warning of warnings) warn(`warning: ${warning}`);
|
|
865
|
+
const { plan } = result;
|
|
866
|
+
log(`${result.dryRun ? "Plan (dry run)" : "Applied"}: ${plan.mode === "new" ? "new project" : "update"} in ${plan.targetDir}`);
|
|
867
|
+
if (plan.template) log(` template: ${plan.template.spec}${plan.template.revision ? ` @ ${plan.template.revision}` : ""}`);
|
|
868
|
+
if (plan.projectName) log(` project name: ${plan.projectName}`);
|
|
869
|
+
for (const [name, range] of Object.entries(plan.addDependencies)) log(` + ${name}@${range}`);
|
|
870
|
+
for (const [name, range] of Object.entries(plan.keptDependencies)) log(` = ${name}@${range} (already declared)`);
|
|
871
|
+
log(` smrt.cookbook.json: ${plan.config}`);
|
|
872
|
+
log("\nNext steps:");
|
|
873
|
+
for (const step of result.nextSteps) log(` ${step}`);
|
|
874
|
+
}
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region src/commands/kitchen/apply.ts
|
|
877
|
+
/**
|
|
878
|
+
* Applying a cookbook the page sent to `smrt kitchen` (#3750): the same
|
|
879
|
+
* validation and apply engine as `smrt cookbook apply`, wrapped as one call
|
|
880
|
+
* that returns the HTTP-shaped outcome the planner shows to the visitor.
|
|
881
|
+
*/
|
|
882
|
+
function createCookbookApplier(options) {
|
|
883
|
+
const log = options.log ?? (() => {});
|
|
884
|
+
return async (text) => {
|
|
885
|
+
let cookbookText = text;
|
|
886
|
+
try {
|
|
887
|
+
cookbookText = JSON.stringify(JSON.parse(text));
|
|
888
|
+
} catch {
|
|
889
|
+
return {
|
|
890
|
+
ok: false,
|
|
891
|
+
status: 400,
|
|
892
|
+
errors: ["The body is not valid JSON."]
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
log("Checking the cookbook against the recipe manifests…");
|
|
897
|
+
const report = options.index ? validateCookbookText(cookbookText, options.index) : await validateCookbookSource(cookbookText, {
|
|
898
|
+
dir: options.dir,
|
|
899
|
+
manifests: options.manifests,
|
|
900
|
+
registry: options.registry
|
|
901
|
+
});
|
|
902
|
+
for (const warning of report.warnings) log(`warning: ${warning}`);
|
|
903
|
+
if (!report.ok || !report.cookbook) return {
|
|
904
|
+
ok: false,
|
|
905
|
+
status: 422,
|
|
906
|
+
errors: report.errors
|
|
907
|
+
};
|
|
908
|
+
log(`Writing the project in ${options.dir}…`);
|
|
909
|
+
const result = applyCookbook({
|
|
910
|
+
cookbook: report.cookbook,
|
|
911
|
+
packages: report.packages,
|
|
912
|
+
dir: options.dir,
|
|
913
|
+
template: options.template,
|
|
914
|
+
install: options.install,
|
|
915
|
+
installOutput: options.installOutput,
|
|
916
|
+
dryRun: options.dryRun,
|
|
917
|
+
cliVersion: cliVersion()
|
|
918
|
+
});
|
|
919
|
+
return {
|
|
920
|
+
ok: true,
|
|
921
|
+
status: 200,
|
|
922
|
+
result,
|
|
923
|
+
body: {
|
|
924
|
+
ok: true,
|
|
925
|
+
dir: result.plan.targetDir,
|
|
926
|
+
mode: result.plan.mode,
|
|
927
|
+
installed: result.installed,
|
|
928
|
+
added: Object.keys(result.plan.addDependencies),
|
|
929
|
+
nextSteps: result.nextSteps
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
} catch (error) {
|
|
933
|
+
if (error instanceof ApplyError) return {
|
|
934
|
+
ok: false,
|
|
935
|
+
status: 409,
|
|
936
|
+
errors: [error.message]
|
|
937
|
+
};
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
status: 500,
|
|
941
|
+
errors: [error instanceof Error ? error.message : String(error)]
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
export { readCookbookSource as a, SMRT_SCOPE as c, downloadPackage as d, extractTarball as f, applyCookbook as g, COOKBOOK_CONFIG_FILE as h, printApplyResult as i, loadRegistryManifest as l, ApplyError as m, asList as n, validateCookbookSource as o, lookupPackage as p, cliVersion as r, validateCookbookText as s, createCookbookApplier as t, resolveRecipeIndex as u };
|