hatchkit 0.1.40 → 0.1.41
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/adopt.d.ts.map +1 -1
- package/dist/adopt.js +305 -73
- package/dist/adopt.js.map +1 -1
- package/dist/deploy/pages.d.ts +41 -0
- package/dist/deploy/pages.d.ts.map +1 -1
- package/dist/deploy/pages.js +360 -13
- package/dist/deploy/pages.js.map +1 -1
- package/dist/deploy/regen-infra.js +4 -0
- package/dist/deploy/regen-infra.js.map +1 -1
- package/dist/deploy/rollback.d.ts.map +1 -1
- package/dist/deploy/rollback.js +14 -0
- package/dist/deploy/rollback.js.map +1 -1
- package/dist/index.js +193 -18
- package/dist/index.js.map +1 -1
- package/dist/inventory.d.ts +37 -0
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +502 -44
- package/dist/inventory.js.map +1 -1
- package/dist/overview.d.ts +101 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +852 -0
- package/dist/overview.js.map +1 -0
- package/dist/prompts.d.ts +22 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +239 -33
- package/dist/prompts.js.map +1 -1
- package/dist/scaffold/infra.d.ts.map +1 -1
- package/dist/scaffold/infra.js +7 -1
- package/dist/scaffold/infra.js.map +1 -1
- package/dist/scaffold/manifest.d.ts +6 -0
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +1 -0
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/pages-heuristics.d.ts +17 -0
- package/dist/scaffold/pages-heuristics.d.ts.map +1 -0
- package/dist/scaffold/pages-heuristics.js +344 -0
- package/dist/scaffold/pages-heuristics.js.map +1 -0
- package/dist/scaffold/pages-mode.d.ts +10 -0
- package/dist/scaffold/pages-mode.d.ts.map +1 -0
- package/dist/scaffold/pages-mode.js +109 -0
- package/dist/scaffold/pages-mode.js.map +1 -0
- package/dist/scaffold/surfaces.d.ts.map +1 -1
- package/dist/scaffold/surfaces.js +12 -1
- package/dist/scaffold/surfaces.js.map +1 -1
- package/dist/utils/cloudflare-api.d.ts +19 -0
- package/dist/utils/cloudflare-api.d.ts.map +1 -1
- package/dist/utils/cloudflare-api.js +16 -0
- package/dist/utils/cloudflare-api.js.map +1 -1
- package/dist/utils/coolify-api.d.ts +9 -0
- package/dist/utils/coolify-api.d.ts.map +1 -1
- package/dist/utils/coolify-api.js +26 -0
- package/dist/utils/coolify-api.js.map +1 -1
- package/dist/utils/run-ledger.d.ts +20 -0
- package/dist/utils/run-ledger.d.ts.map +1 -1
- package/dist/utils/run-ledger.js.map +1 -1
- package/package.json +1 -1
package/dist/inventory.js
CHANGED
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Everything is read-only. No mutations. Safe to run anywhere.
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
27
27
|
import { join, resolve } from "node:path";
|
|
28
28
|
import { confirm, input } from "@inquirer/prompts";
|
|
29
29
|
import chalk from "chalk";
|
|
30
30
|
import { getCoolifyConfig, getDnsConfig, getGlitchtipConfig, getOpenpanelConfig, getResendConfig, getS3Config, getStripeConfig, } from "./config.js";
|
|
31
31
|
import { locateEnvKeysFile, locateEnvProductionFile } from "./deploy/keys.js";
|
|
32
|
-
import { MANIFEST_FILENAME, readManifest } from "./scaffold/manifest.js";
|
|
32
|
+
import { MANIFEST_FILENAME, MANIFEST_VERSION, readManifest, } from "./scaffold/manifest.js";
|
|
33
33
|
import { CloudflareApi } from "./utils/cloudflare-api.js";
|
|
34
34
|
import { CoolifyApi } from "./utils/coolify-api.js";
|
|
35
35
|
import { exec, execOk } from "./utils/exec.js";
|
|
@@ -42,10 +42,40 @@ export async function runInventory(cwd, opts = {}) {
|
|
|
42
42
|
autoAccept: opts.yes ?? false,
|
|
43
43
|
});
|
|
44
44
|
if (opts.json) {
|
|
45
|
-
|
|
45
|
+
// Sets serialize as `{}` by default — surface them as arrays so
|
|
46
|
+
// JSON consumers can actually read the detected signals.
|
|
47
|
+
console.log(JSON.stringify(report, (_k, v) => (v instanceof Set ? Array.from(v).sort() : v), 2));
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
48
50
|
console.log(renderInventoryHuman(report));
|
|
51
|
+
// Persist inferred identity as a minimal `.hatchkit.json` unless
|
|
52
|
+
// suppressed. Skip when there's already a manifest (adopt owns it
|
|
53
|
+
// — we don't overwrite), when the inputs aren't sufficient (name +
|
|
54
|
+
// domain are required by the schema), or when `--no-save` was passed.
|
|
55
|
+
if (opts.noSave)
|
|
56
|
+
return;
|
|
57
|
+
if (report.local.manifestPresent)
|
|
58
|
+
return;
|
|
59
|
+
if (!report.inferred.name || !report.inferred.domain)
|
|
60
|
+
return;
|
|
61
|
+
const absCwd = resolve(cwd);
|
|
62
|
+
const writeIt = () => {
|
|
63
|
+
writeMinimalManifest(absCwd, report.inferred, report.local);
|
|
64
|
+
console.log(chalk.dim(` Wrote minimal ${MANIFEST_FILENAME}. Run \`hatchkit adopt --resume\` to wire features + deploy config.`));
|
|
65
|
+
};
|
|
66
|
+
if (opts.save) {
|
|
67
|
+
writeIt();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Only ask in interactive mode (TTY + not --yes).
|
|
71
|
+
if (!process.stdin.isTTY || opts.yes)
|
|
72
|
+
return;
|
|
73
|
+
const ok = await confirm({
|
|
74
|
+
message: `Save inferred identity as a minimal ${MANIFEST_FILENAME}? (Run \`hatchkit adopt --resume\` afterwards to flesh out features + deploy config.)`,
|
|
75
|
+
default: true,
|
|
76
|
+
});
|
|
77
|
+
if (ok)
|
|
78
|
+
writeIt();
|
|
49
79
|
}
|
|
50
80
|
export async function collectInventory(cwd, opts = {}) {
|
|
51
81
|
const absCwd = resolve(cwd);
|
|
@@ -67,19 +97,25 @@ export async function collectInventory(cwd, opts = {}) {
|
|
|
67
97
|
if (opts.interactive) {
|
|
68
98
|
identity = await promptForGaps(local, inferred, sources, !!opts.autoAccept);
|
|
69
99
|
}
|
|
100
|
+
// Per-provider expectation flags — drives which `missing` findings
|
|
101
|
+
// surface as red ✗ vs dim ·. A library with no Coolify-deploy signals
|
|
102
|
+
// gets dim · for "no Coolify app named foo" (expected absence), but a
|
|
103
|
+
// project whose .env.development declares STRIPE_* gets red ✗ if no
|
|
104
|
+
// matching webhook exists (genuine missing).
|
|
105
|
+
const expectations = computeExpectations(local, identity);
|
|
70
106
|
// Provider scans — every one is best-effort and returns its own
|
|
71
107
|
// findings + skip reason. Running them in parallel keeps wall-time
|
|
72
108
|
// close to the slowest single round-trip.
|
|
73
109
|
const scanResults = await Promise.all([
|
|
74
|
-
scanCoolify(identity),
|
|
75
|
-
scanDns(identity),
|
|
76
|
-
scanR2(identity, local.manifest),
|
|
110
|
+
scanCoolify(identity, expectations.coolify),
|
|
111
|
+
scanDns(identity, expectations.dns),
|
|
112
|
+
scanR2(identity, local.manifest, expectations.r2),
|
|
77
113
|
scanS3Other(identity),
|
|
78
|
-
scanGitHub(identity),
|
|
79
|
-
scanResend(identity),
|
|
80
|
-
scanGlitchtip(identity),
|
|
81
|
-
scanOpenpanel(identity),
|
|
82
|
-
scanStripe(identity),
|
|
114
|
+
scanGitHub(identity, { github: expectations.github, githubPages: expectations.githubPages }),
|
|
115
|
+
scanResend(identity, expectations.resend),
|
|
116
|
+
scanGlitchtip(identity, expectations.glitchtip),
|
|
117
|
+
scanOpenpanel(identity, expectations.openpanel),
|
|
118
|
+
scanStripe(identity, expectations.stripe),
|
|
83
119
|
]);
|
|
84
120
|
const findings = [];
|
|
85
121
|
const skipped = [];
|
|
@@ -94,7 +130,8 @@ export async function collectInventory(cwd, opts = {}) {
|
|
|
94
130
|
findings.push(...driftFindings);
|
|
95
131
|
const drifts = findings.filter((f) => f.status === "drift");
|
|
96
132
|
const present = findings.filter((f) => f.status === "present").length;
|
|
97
|
-
const
|
|
133
|
+
const missingAll = findings.filter((f) => f.status === "missing");
|
|
134
|
+
const expectedMissing = missingAll.filter((f) => f.expected).length;
|
|
98
135
|
return {
|
|
99
136
|
cliVersion: getCliVersion(),
|
|
100
137
|
cwd: absCwd,
|
|
@@ -104,7 +141,13 @@ export async function collectInventory(cwd, opts = {}) {
|
|
|
104
141
|
findings,
|
|
105
142
|
drifts,
|
|
106
143
|
skipped,
|
|
107
|
-
summary: {
|
|
144
|
+
summary: {
|
|
145
|
+
present,
|
|
146
|
+
drift: drifts.length,
|
|
147
|
+
missing: missingAll.length,
|
|
148
|
+
expectedMissing,
|
|
149
|
+
skipped: skipped.length,
|
|
150
|
+
},
|
|
108
151
|
};
|
|
109
152
|
}
|
|
110
153
|
// ---------------------------------------------------------------------------
|
|
@@ -250,6 +293,13 @@ function inferLocal(cwd) {
|
|
|
250
293
|
}
|
|
251
294
|
}
|
|
252
295
|
const envKeysPresent = !!locateEnvKeysFile(cwd);
|
|
296
|
+
// Provider expectation signals — read plaintext .env.* files and
|
|
297
|
+
// every package.json under the project for hints about which
|
|
298
|
+
// providers this project actually depends on. Used to mark `missing`
|
|
299
|
+
// findings as `expected: true` so the renderer only shows red ✗ when
|
|
300
|
+
// local state declares the resource should exist.
|
|
301
|
+
const envSignals = collectEnvSignals(cwd, serverDir, clientDir);
|
|
302
|
+
const packageDeps = collectPackageDeps(cwd, serverDir, clientDir);
|
|
253
303
|
// `.git` lives at the repo root — in a worktree it's a file pointing
|
|
254
304
|
// at the main repo, in a normal clone it's a directory. Either form
|
|
255
305
|
// is fine for existsSync. Walk up from cwd so running `hatchkit
|
|
@@ -273,7 +323,131 @@ function inferLocal(cwd) {
|
|
|
273
323
|
cnameFile,
|
|
274
324
|
dotenvxEncrypted,
|
|
275
325
|
envKeysPresent,
|
|
326
|
+
envSignals,
|
|
327
|
+
packageDeps,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Expectation-signal collectors (used to mark findings as `expected`)
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
/** Walk plaintext .env.example / .env.development files at the project
|
|
334
|
+
* root and any detected server/client dirs, returning the set of
|
|
335
|
+
* env-var-name prefixes encountered. Encrypted .env.production is
|
|
336
|
+
* intentionally NOT decrypted — we read declarative templates only. */
|
|
337
|
+
function collectEnvSignals(cwd, serverDir, clientDir) {
|
|
338
|
+
const signals = new Set();
|
|
339
|
+
const filenames = [".env.example", ".env.development", ".env"];
|
|
340
|
+
const dirs = [cwd, serverDir, clientDir].filter((d) => !!d);
|
|
341
|
+
// Patterns we recognize — prefix → signal name. Keep this list
|
|
342
|
+
// tight; over-broad matches lead to spurious "expected" flags.
|
|
343
|
+
const patterns = [
|
|
344
|
+
{ re: /^\s*RESEND_/m, signal: "RESEND" },
|
|
345
|
+
{ re: /^\s*GLITCHTIP_DSN|^\s*PUBLIC_GLITCHTIP_DSN/m, signal: "GLITCHTIP" },
|
|
346
|
+
{ re: /^\s*SENTRY_DSN|^\s*PUBLIC_SENTRY_DSN/m, signal: "SENTRY" },
|
|
347
|
+
{ re: /^\s*OPENPANEL_|^\s*PUBLIC_OPENPANEL_/m, signal: "OPENPANEL" },
|
|
348
|
+
{ re: /^\s*STRIPE_/m, signal: "STRIPE" },
|
|
349
|
+
{ re: /^\s*R2_/m, signal: "R2" },
|
|
350
|
+
{ re: /^\s*S3_|^\s*AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|REGION)/m, signal: "S3" },
|
|
351
|
+
];
|
|
352
|
+
for (const dir of dirs) {
|
|
353
|
+
for (const name of filenames) {
|
|
354
|
+
const p = join(dir, name);
|
|
355
|
+
if (!existsSync(p))
|
|
356
|
+
continue;
|
|
357
|
+
let body;
|
|
358
|
+
try {
|
|
359
|
+
body = readFileSync(p, "utf-8");
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Strip commented-out lines — `# RESEND_API_KEY=...` shouldn't
|
|
365
|
+
// count as a signal that the project uses Resend.
|
|
366
|
+
const live = body
|
|
367
|
+
.split("\n")
|
|
368
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
369
|
+
.join("\n");
|
|
370
|
+
for (const { re, signal } of patterns) {
|
|
371
|
+
if (re.test(live))
|
|
372
|
+
signals.add(signal);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return signals;
|
|
377
|
+
}
|
|
378
|
+
/** Read every package.json under the project (root + server/client
|
|
379
|
+
* dirs) and return the union of declared dependency names (deps +
|
|
380
|
+
* devDeps + peerDeps). Cheap heuristic — we don't follow workspace
|
|
381
|
+
* globs, just probe the conventional monorepo locations. */
|
|
382
|
+
function collectPackageDeps(cwd, serverDir, clientDir) {
|
|
383
|
+
const out = new Set();
|
|
384
|
+
const dirs = [cwd, serverDir, clientDir].filter((d) => !!d);
|
|
385
|
+
for (const dir of dirs) {
|
|
386
|
+
const p = join(dir, "package.json");
|
|
387
|
+
if (!existsSync(p))
|
|
388
|
+
continue;
|
|
389
|
+
try {
|
|
390
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
391
|
+
for (const block of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
|
|
392
|
+
if (!block)
|
|
393
|
+
continue;
|
|
394
|
+
for (const name of Object.keys(block))
|
|
395
|
+
out.add(name);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// Unparseable package.json — skip.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Minimal `.hatchkit.json` writer
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
//
|
|
408
|
+
// When inventory saves inferred identity it writes a minimal manifest
|
|
409
|
+
// that follows the full schema (so every other hatchkit command — adopt,
|
|
410
|
+
// update, sync, keys — can read it) but with conservative defaults for
|
|
411
|
+
// fields inventory can't reliably infer. Run `hatchkit adopt --resume`
|
|
412
|
+
// afterwards to flesh out features, surfaces, ports, etc. — adopt's
|
|
413
|
+
// stepper reads this manifest as its starting state.
|
|
414
|
+
export function writeMinimalManifest(cwd, identity, local) {
|
|
415
|
+
if (!identity.name || !identity.domain) {
|
|
416
|
+
throw new Error("Can't write .hatchkit.json without an inferred name and domain. " +
|
|
417
|
+
"Pass --name / --domain or run inventory interactively to provide them.");
|
|
418
|
+
}
|
|
419
|
+
// Surfaces from local layout — both dirs → "both", just one → that
|
|
420
|
+
// one, neither → fall back to "both" (most common scaffold shape).
|
|
421
|
+
const surfaces = local.serverDir && local.clientDir
|
|
422
|
+
? "both"
|
|
423
|
+
: local.serverDir
|
|
424
|
+
? "server-only"
|
|
425
|
+
: local.clientDir
|
|
426
|
+
? "client-only"
|
|
427
|
+
: "both";
|
|
428
|
+
const manifest = {
|
|
429
|
+
version: MANIFEST_VERSION,
|
|
430
|
+
cliVersion: getCliVersion(),
|
|
431
|
+
scaffoldedAt: new Date().toISOString(),
|
|
432
|
+
name: identity.name,
|
|
433
|
+
domain: identity.domain,
|
|
434
|
+
features: [],
|
|
435
|
+
mlServices: [],
|
|
436
|
+
// "none" is the conservative default — even if local deps signal R2
|
|
437
|
+
// usage, we don't claim ownership of buckets we haven't provisioned.
|
|
438
|
+
// `hatchkit adopt --resume` will prompt for the real value.
|
|
439
|
+
s3Provider: "none",
|
|
440
|
+
deployTarget: "existing",
|
|
441
|
+
surfaces,
|
|
442
|
+
// Conventional defaults — `hatchkit adopt --resume` lets the user
|
|
443
|
+
// override if the project actually uses different ports.
|
|
444
|
+
ports: { server: 3000, client: 5173 },
|
|
276
445
|
};
|
|
446
|
+
writeManifestFile(cwd, manifest);
|
|
447
|
+
return manifest;
|
|
448
|
+
}
|
|
449
|
+
function writeManifestFile(cwd, manifest) {
|
|
450
|
+
writeFileSync(join(cwd, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
277
451
|
}
|
|
278
452
|
function findGitRoot(startDir) {
|
|
279
453
|
let dir = startDir;
|
|
@@ -306,7 +480,8 @@ function firstExistingDir(root, rels) {
|
|
|
306
480
|
function inferIdentity(local, override) {
|
|
307
481
|
const sources = {};
|
|
308
482
|
const out = {};
|
|
309
|
-
// name: flag > manifest > package.json > basename(cwd)
|
|
483
|
+
// name: flag > manifest > identity-file > package.json > basename(cwd)
|
|
484
|
+
// name: flag > manifest > package.json > basename(cwd)
|
|
310
485
|
if (override.name) {
|
|
311
486
|
out.name = override.name;
|
|
312
487
|
sources.name = "flag";
|
|
@@ -329,10 +504,9 @@ function inferIdentity(local, override) {
|
|
|
329
504
|
sources.name = "cwd-basename";
|
|
330
505
|
}
|
|
331
506
|
}
|
|
332
|
-
// domain: flag > manifest > CNAME file
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// domain patterns against any zone we list.
|
|
507
|
+
// domain: flag > manifest > CNAME file. We deliberately don't derive
|
|
508
|
+
// a domain from the project name — too speculative, and the matching
|
|
509
|
+
// layer already tries common domain patterns against any zone we list.
|
|
336
510
|
if (override.domain) {
|
|
337
511
|
out.domain = override.domain;
|
|
338
512
|
sources.domain = "flag";
|
|
@@ -345,15 +519,11 @@ function inferIdentity(local, override) {
|
|
|
345
519
|
out.domain = local.cnameFile.content;
|
|
346
520
|
sources.domain = "cname-file";
|
|
347
521
|
}
|
|
348
|
-
// repo: flag
|
|
522
|
+
// repo: flag (else filled in by caller from git remote)
|
|
349
523
|
if (override.repo) {
|
|
350
524
|
out.repo = override.repo;
|
|
351
525
|
sources.repo = "flag";
|
|
352
526
|
}
|
|
353
|
-
else {
|
|
354
|
-
// git remote is resolved async — leave undefined here; the caller
|
|
355
|
-
// fills it via resolveGitRemote (run before prompting).
|
|
356
|
-
}
|
|
357
527
|
return { input: out, sources };
|
|
358
528
|
}
|
|
359
529
|
async function resolveGitRemote(local) {
|
|
@@ -522,7 +692,46 @@ function rel(cwd, abs) {
|
|
|
522
692
|
return abs.slice(cwd.length + 1);
|
|
523
693
|
return abs;
|
|
524
694
|
}
|
|
525
|
-
|
|
695
|
+
export function computeExpectations(local, identity) {
|
|
696
|
+
const env = local.envSignals;
|
|
697
|
+
const deps = local.packageDeps;
|
|
698
|
+
const hasManifestBuckets = (() => {
|
|
699
|
+
const b = local.manifest?.s3Buckets;
|
|
700
|
+
if (!b)
|
|
701
|
+
return false;
|
|
702
|
+
return Object.values(b).some((v) => v && typeof v === "object");
|
|
703
|
+
})();
|
|
704
|
+
return {
|
|
705
|
+
// Coolify is the project's deploy target whenever there's a manifest
|
|
706
|
+
// (every hatchkit-scaffolded project deploys there) or a deploy
|
|
707
|
+
// workflow committed.
|
|
708
|
+
coolify: !!local.manifest || !!local.deployWorkflowPath,
|
|
709
|
+
// DNS is always relevant when we know a domain — the user wouldn't
|
|
710
|
+
// have a domain unless they intended to route something to it.
|
|
711
|
+
dns: !!identity.domain,
|
|
712
|
+
r2: hasManifestBuckets || env.has("R2") || env.has("S3") || deps.has("@aws-sdk/client-s3"),
|
|
713
|
+
github: !!identity.repo,
|
|
714
|
+
// Pages is expected when a deploy workflow is committed or the
|
|
715
|
+
// repo has a CNAME file at one of the conventional locations.
|
|
716
|
+
githubPages: !!local.ghPagesWorkflowPath || !!local.cnameFile,
|
|
717
|
+
resend: env.has("RESEND") || deps.has("resend"),
|
|
718
|
+
// The Sentry SDK works against GlitchTip (same wire protocol), so
|
|
719
|
+
// either signal counts. Same for an explicit GLITCHTIP_DSN.
|
|
720
|
+
glitchtip: env.has("GLITCHTIP") ||
|
|
721
|
+
env.has("SENTRY") ||
|
|
722
|
+
deps.has("glitchtip") ||
|
|
723
|
+
hasDepMatching(deps, /^@sentry\//),
|
|
724
|
+
openpanel: env.has("OPENPANEL") || hasDepMatching(deps, /^@openpanel\//) || deps.has("openpanel"),
|
|
725
|
+
stripe: env.has("STRIPE") || deps.has("stripe") || deps.has("@stripe/stripe-js"),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function hasDepMatching(deps, re) {
|
|
729
|
+
for (const d of deps)
|
|
730
|
+
if (re.test(d))
|
|
731
|
+
return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
async function scanCoolify(input, expected) {
|
|
526
735
|
const provider = "coolify";
|
|
527
736
|
const findings = [];
|
|
528
737
|
const skipped = [];
|
|
@@ -602,6 +811,7 @@ async function scanCoolify(input) {
|
|
|
602
811
|
kind: "application",
|
|
603
812
|
identity: input.name,
|
|
604
813
|
status: "missing",
|
|
814
|
+
expected,
|
|
605
815
|
detail: `no Coolify project or app named ${wantedNames.join(" / ")} (${apps.length} app(s) total)`,
|
|
606
816
|
});
|
|
607
817
|
}
|
|
@@ -634,7 +844,7 @@ function collectFqdns(app) {
|
|
|
634
844
|
function nameAliases(name) {
|
|
635
845
|
return [name, `${name}-server`, `${name}-client`, `${name}-web`, `${name}-api`];
|
|
636
846
|
}
|
|
637
|
-
async function scanDns(input) {
|
|
847
|
+
async function scanDns(input, expected) {
|
|
638
848
|
const provider = "dns";
|
|
639
849
|
const findings = [];
|
|
640
850
|
const skipped = [];
|
|
@@ -677,6 +887,7 @@ async function scanDns(input) {
|
|
|
677
887
|
kind: "zone",
|
|
678
888
|
identity: apex,
|
|
679
889
|
status: "missing",
|
|
890
|
+
expected,
|
|
680
891
|
detail: "no Cloudflare zone for this apex",
|
|
681
892
|
});
|
|
682
893
|
return { provider, findings, skipped };
|
|
@@ -745,7 +956,7 @@ function relevantRecordProbes(input) {
|
|
|
745
956
|
}
|
|
746
957
|
return out;
|
|
747
958
|
}
|
|
748
|
-
async function scanR2(input, manifest) {
|
|
959
|
+
async function scanR2(input, manifest, expected) {
|
|
749
960
|
const provider = "s3:r2";
|
|
750
961
|
const findings = [];
|
|
751
962
|
const skipped = [];
|
|
@@ -839,6 +1050,7 @@ async function scanR2(input, manifest) {
|
|
|
839
1050
|
kind: "bucket",
|
|
840
1051
|
identity: input.name ?? "(candidates)",
|
|
841
1052
|
status: "missing",
|
|
1053
|
+
expected,
|
|
842
1054
|
detail: `no R2 bucket matches ${Array.from(candidates).join(" / ")} (account ${accountId.slice(0, 6)}…)`,
|
|
843
1055
|
});
|
|
844
1056
|
}
|
|
@@ -871,7 +1083,7 @@ async function scanS3Other(_input) {
|
|
|
871
1083
|
}
|
|
872
1084
|
return { provider, findings, skipped };
|
|
873
1085
|
}
|
|
874
|
-
async function scanGitHub(input) {
|
|
1086
|
+
async function scanGitHub(input, expects) {
|
|
875
1087
|
const provider = "github";
|
|
876
1088
|
const findings = [];
|
|
877
1089
|
const skipped = [];
|
|
@@ -920,6 +1132,7 @@ async function scanGitHub(input) {
|
|
|
920
1132
|
kind: "repository",
|
|
921
1133
|
identity: input.repo,
|
|
922
1134
|
status: "missing",
|
|
1135
|
+
expected: expects.github,
|
|
923
1136
|
detail: res.stderr.trim().split("\n")[0],
|
|
924
1137
|
});
|
|
925
1138
|
return { provider, findings, skipped, raw: { repoInfo } };
|
|
@@ -959,6 +1172,7 @@ async function scanGitHub(input) {
|
|
|
959
1172
|
kind: "page-site",
|
|
960
1173
|
identity: input.repo,
|
|
961
1174
|
status: "missing",
|
|
1175
|
+
expected: expects.githubPages,
|
|
962
1176
|
detail: "Pages is not enabled on this repo",
|
|
963
1177
|
});
|
|
964
1178
|
}
|
|
@@ -1019,7 +1233,7 @@ async function scanGitHub(input) {
|
|
|
1019
1233
|
}
|
|
1020
1234
|
return { provider, findings, skipped, raw: { repoInfo, pages } };
|
|
1021
1235
|
}
|
|
1022
|
-
async function scanResend(input) {
|
|
1236
|
+
async function scanResend(input, expected) {
|
|
1023
1237
|
const provider = "resend";
|
|
1024
1238
|
const findings = [];
|
|
1025
1239
|
const skipped = [];
|
|
@@ -1047,6 +1261,7 @@ async function scanResend(input) {
|
|
|
1047
1261
|
kind: "verified-domain",
|
|
1048
1262
|
identity: input.domain,
|
|
1049
1263
|
status: "missing",
|
|
1264
|
+
expected,
|
|
1050
1265
|
detail: `no Resend domain entry for ${input.domain} (${(body.data ?? []).length} domain(s) total)`,
|
|
1051
1266
|
});
|
|
1052
1267
|
}
|
|
@@ -1070,7 +1285,7 @@ async function scanResend(input) {
|
|
|
1070
1285
|
}
|
|
1071
1286
|
return { provider, findings, skipped };
|
|
1072
1287
|
}
|
|
1073
|
-
async function scanGlitchtip(input) {
|
|
1288
|
+
async function scanGlitchtip(input, expected) {
|
|
1074
1289
|
const provider = "glitchtip";
|
|
1075
1290
|
const findings = [];
|
|
1076
1291
|
const skipped = [];
|
|
@@ -1097,6 +1312,7 @@ async function scanGlitchtip(input) {
|
|
|
1097
1312
|
kind: "project",
|
|
1098
1313
|
identity: input.name,
|
|
1099
1314
|
status: "missing",
|
|
1315
|
+
expected,
|
|
1100
1316
|
detail: `no GlitchTip project matching ${wanted.join(" / ")} (${body.length} total in org)`,
|
|
1101
1317
|
});
|
|
1102
1318
|
}
|
|
@@ -1120,7 +1336,7 @@ async function scanGlitchtip(input) {
|
|
|
1120
1336
|
}
|
|
1121
1337
|
return { provider, findings, skipped };
|
|
1122
1338
|
}
|
|
1123
|
-
async function scanOpenpanel(input) {
|
|
1339
|
+
async function scanOpenpanel(input, expected) {
|
|
1124
1340
|
const provider = "openpanel";
|
|
1125
1341
|
const findings = [];
|
|
1126
1342
|
const skipped = [];
|
|
@@ -1158,6 +1374,7 @@ async function scanOpenpanel(input) {
|
|
|
1158
1374
|
kind: "project",
|
|
1159
1375
|
identity: input.name,
|
|
1160
1376
|
status: "missing",
|
|
1377
|
+
expected,
|
|
1161
1378
|
detail: `no OpenPanel project matching ${wanted.join(" / ")} (${projects.length} total)`,
|
|
1162
1379
|
});
|
|
1163
1380
|
}
|
|
@@ -1180,7 +1397,7 @@ async function scanOpenpanel(input) {
|
|
|
1180
1397
|
}
|
|
1181
1398
|
return { provider, findings, skipped };
|
|
1182
1399
|
}
|
|
1183
|
-
async function scanStripe(input) {
|
|
1400
|
+
async function scanStripe(input, expected) {
|
|
1184
1401
|
const provider = "stripe";
|
|
1185
1402
|
const findings = [];
|
|
1186
1403
|
const skipped = [];
|
|
@@ -1215,6 +1432,7 @@ async function scanStripe(input) {
|
|
|
1215
1432
|
kind: "webhook-endpoint",
|
|
1216
1433
|
identity: `${mode} mode`,
|
|
1217
1434
|
status: "missing",
|
|
1435
|
+
expected,
|
|
1218
1436
|
detail: `no webhook endpoint with URL containing ${input.domain} (${(body.data ?? []).length} endpoint(s) in ${mode} mode)`,
|
|
1219
1437
|
});
|
|
1220
1438
|
}
|
|
@@ -1486,13 +1704,7 @@ export function renderInventoryHuman(report) {
|
|
|
1486
1704
|
continue;
|
|
1487
1705
|
lines.push(chalk.bold(` ${providerKey}`));
|
|
1488
1706
|
for (const f of findings) {
|
|
1489
|
-
const icon = f
|
|
1490
|
-
? chalk.green("✓")
|
|
1491
|
-
: f.status === "missing"
|
|
1492
|
-
? chalk.red("✗")
|
|
1493
|
-
: f.status === "drift"
|
|
1494
|
-
? chalk.yellow("⚠")
|
|
1495
|
-
: chalk.dim("·");
|
|
1707
|
+
const icon = findingIcon(f);
|
|
1496
1708
|
const kind = chalk.dim(`(${f.kind})`);
|
|
1497
1709
|
const detail = f.detail ? chalk.dim(` — ${f.detail}`) : "";
|
|
1498
1710
|
lines.push(` ${icon} ${f.identity} ${kind}${detail}`);
|
|
@@ -1506,12 +1718,10 @@ export function renderInventoryHuman(report) {
|
|
|
1506
1718
|
}
|
|
1507
1719
|
lines.push("");
|
|
1508
1720
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
: chalk.dim("0 missing")} ${chalk.dim(`${report.summary.skipped} skipped`)}`);
|
|
1514
|
-
lines.push("");
|
|
1721
|
+
// Final one-page summary — compact per-provider roll-up, no detail
|
|
1722
|
+
// fluff. The reader can scroll up for specifics; this block answers
|
|
1723
|
+
// "what does this project have, and what needs attention?" at a glance.
|
|
1724
|
+
lines.push(renderInventorySummary(report));
|
|
1515
1725
|
return lines.join("\n");
|
|
1516
1726
|
}
|
|
1517
1727
|
function sourceTag(s) {
|
|
@@ -1519,4 +1729,252 @@ function sourceTag(s) {
|
|
|
1519
1729
|
return chalk.dim(" (will prompt)");
|
|
1520
1730
|
return chalk.dim(` ← ${s}`);
|
|
1521
1731
|
}
|
|
1732
|
+
/** Status icon for a single finding. Red ✗ is reserved for `missing`
|
|
1733
|
+
* findings the project actually expects (declared in manifest, env,
|
|
1734
|
+
* package deps, workflows). An unexpected absence — e.g. "no Coolify
|
|
1735
|
+
* app named foo" for a library that doesn't deploy — is dim · instead. */
|
|
1736
|
+
function findingIcon(f) {
|
|
1737
|
+
if (f.status === "present")
|
|
1738
|
+
return chalk.green("✓");
|
|
1739
|
+
if (f.status === "drift")
|
|
1740
|
+
return chalk.yellow("⚠");
|
|
1741
|
+
if (f.status === "missing")
|
|
1742
|
+
return f.expected ? chalk.red("✗") : chalk.dim("·");
|
|
1743
|
+
return chalk.dim("·");
|
|
1744
|
+
}
|
|
1745
|
+
function renderInventorySummary(report) {
|
|
1746
|
+
const lines = [];
|
|
1747
|
+
lines.push(chalk.dim(` ${"─".repeat(58)}`));
|
|
1748
|
+
lines.push(chalk.bold(" Summary"));
|
|
1749
|
+
lines.push("");
|
|
1750
|
+
// Group findings by *effective* provider — drift findings get
|
|
1751
|
+
// re-attributed to the provider they're about, so each provider's
|
|
1752
|
+
// summary line reflects all its state (including drift).
|
|
1753
|
+
const grouped = new Map();
|
|
1754
|
+
for (const f of report.findings) {
|
|
1755
|
+
const effective = f.provider === "drift" ? driftToProvider(f.kind) : f.provider;
|
|
1756
|
+
const list = grouped.get(effective);
|
|
1757
|
+
if (list)
|
|
1758
|
+
list.push(f);
|
|
1759
|
+
else
|
|
1760
|
+
grouped.set(effective, [f]);
|
|
1761
|
+
}
|
|
1762
|
+
const rolls = [];
|
|
1763
|
+
for (const [providerKey, findings] of grouped) {
|
|
1764
|
+
rolls.push(rollUpProvider(providerKey, findings));
|
|
1765
|
+
}
|
|
1766
|
+
// Append skipped providers (not in grouped) as dim rows so the
|
|
1767
|
+
// summary lists every provider hatchkit knows about, not just the
|
|
1768
|
+
// ones that returned findings.
|
|
1769
|
+
const seen = new Set(grouped.keys());
|
|
1770
|
+
for (const s of report.skipped) {
|
|
1771
|
+
if (seen.has(s.provider))
|
|
1772
|
+
continue;
|
|
1773
|
+
seen.add(s.provider);
|
|
1774
|
+
rolls.push({
|
|
1775
|
+
label: providerLabel(s.provider),
|
|
1776
|
+
icon: chalk.dim("·"),
|
|
1777
|
+
text: chalk.dim(s.reason),
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
const labelWidth = Math.max(...rolls.map((r) => r.label.length), 10);
|
|
1781
|
+
for (const r of rolls) {
|
|
1782
|
+
lines.push(` ${r.label.padEnd(labelWidth + 2)} ${r.icon} ${r.text}`);
|
|
1783
|
+
}
|
|
1784
|
+
// Bottom-line takeaway — counts only `expected` missing (the
|
|
1785
|
+
// actionable subset). Unexpected absences ("no Coolify app named foo"
|
|
1786
|
+
// for a CLI library) are excluded; they're not problems to fix.
|
|
1787
|
+
lines.push("");
|
|
1788
|
+
const drift = report.summary.drift;
|
|
1789
|
+
const missing = report.summary.expectedMissing;
|
|
1790
|
+
const present = report.summary.present;
|
|
1791
|
+
if (drift > 0 || missing > 0) {
|
|
1792
|
+
const parts = [];
|
|
1793
|
+
if (drift > 0) {
|
|
1794
|
+
parts.push(chalk.yellow(`⚠ ${drift} drift${drift === 1 ? "" : "s"} to reconcile`));
|
|
1795
|
+
}
|
|
1796
|
+
if (missing > 0) {
|
|
1797
|
+
parts.push(chalk.red(`✗ ${missing} expected resource${missing === 1 ? "" : "s"} missing`));
|
|
1798
|
+
}
|
|
1799
|
+
lines.push(` ${parts.join(chalk.dim(" · "))}`);
|
|
1800
|
+
}
|
|
1801
|
+
else if (present > 0) {
|
|
1802
|
+
lines.push(` ${chalk.green("✓ All clear")} — ${present} resource${present === 1 ? "" : "s"} tracked, nothing out of sync.`);
|
|
1803
|
+
}
|
|
1804
|
+
else {
|
|
1805
|
+
lines.push(chalk.dim(" Nothing matched — try `--name`, `--domain`, or `--repo` to narrow."));
|
|
1806
|
+
}
|
|
1807
|
+
lines.push("");
|
|
1808
|
+
return lines.join("\n");
|
|
1809
|
+
}
|
|
1810
|
+
/** Drift findings live under provider "drift" in `report.findings`,
|
|
1811
|
+
* but conceptually they belong to the provider they describe. Pin
|
|
1812
|
+
* each drift `kind` to its source provider for the summary roll-up. */
|
|
1813
|
+
function driftToProvider(driftKind) {
|
|
1814
|
+
if (driftKind.startsWith("coolify"))
|
|
1815
|
+
return "coolify";
|
|
1816
|
+
if (driftKind === "bucket" || driftKind === "bucket-cors")
|
|
1817
|
+
return "s3:r2";
|
|
1818
|
+
if (driftKind.startsWith("github-pages"))
|
|
1819
|
+
return "github-pages";
|
|
1820
|
+
if (driftKind === "missing-secret")
|
|
1821
|
+
return "github";
|
|
1822
|
+
return "drift";
|
|
1823
|
+
}
|
|
1824
|
+
function rollUpProvider(providerKey, findings) {
|
|
1825
|
+
const drifts = findings.filter((f) => f.status === "drift");
|
|
1826
|
+
const present = findings.filter((f) => f.status === "present");
|
|
1827
|
+
const missing = findings.filter((f) => f.status === "missing");
|
|
1828
|
+
const label = providerLabel(providerKey);
|
|
1829
|
+
if (drifts.length > 0) {
|
|
1830
|
+
return {
|
|
1831
|
+
label,
|
|
1832
|
+
icon: chalk.yellow("⚠"),
|
|
1833
|
+
text: chalk.yellow(`${drifts.length} drift${drifts.length === 1 ? "" : "s"} — see above`),
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
if (present.length > 0) {
|
|
1837
|
+
return {
|
|
1838
|
+
label,
|
|
1839
|
+
icon: chalk.green("✓"),
|
|
1840
|
+
text: summarizePresent(providerKey, present, missing),
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
if (missing.length > 0) {
|
|
1844
|
+
// Red ✗ only when the project locally declares this resource
|
|
1845
|
+
// should exist. Otherwise dim · with a softer "no match" label —
|
|
1846
|
+
// a CLI library shouldn't get a red mark for "no Coolify app".
|
|
1847
|
+
const anyExpected = missing.some((f) => f.expected);
|
|
1848
|
+
return {
|
|
1849
|
+
label,
|
|
1850
|
+
icon: anyExpected ? chalk.red("✗") : chalk.dim("·"),
|
|
1851
|
+
text: anyExpected
|
|
1852
|
+
? chalk.red(summarizeMissing(providerKey))
|
|
1853
|
+
: chalk.dim("no match (not declared by this project)"),
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
// Only `info`-level findings → no actionable state to surface.
|
|
1857
|
+
return {
|
|
1858
|
+
label,
|
|
1859
|
+
icon: chalk.dim("·"),
|
|
1860
|
+
text: chalk.dim(findings[0]?.identity ?? ""),
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
/** Compact "what's here" string for a provider that has at least one
|
|
1864
|
+
* present finding. Per-provider tuned to keep the line short. */
|
|
1865
|
+
function summarizePresent(providerKey, present, missing) {
|
|
1866
|
+
const partial = missing.length > 0 ? chalk.dim(` (${missing.length} missing)`) : "";
|
|
1867
|
+
switch (providerKey) {
|
|
1868
|
+
case "coolify": {
|
|
1869
|
+
const apps = present.filter((f) => f.kind === "application").map((f) => f.identity);
|
|
1870
|
+
const projects = present.filter((f) => f.kind === "project");
|
|
1871
|
+
const parts = [];
|
|
1872
|
+
if (apps.length)
|
|
1873
|
+
parts.push(`${apps.length} app: ${apps.join(", ")}`);
|
|
1874
|
+
if (projects.length)
|
|
1875
|
+
parts.push(`${projects.length} project${projects.length === 1 ? "" : "s"}`);
|
|
1876
|
+
return (parts.join(", ") || present[0].identity) + partial;
|
|
1877
|
+
}
|
|
1878
|
+
case "dns": {
|
|
1879
|
+
const zone = present.find((f) => f.kind === "zone");
|
|
1880
|
+
const records = present.filter((f) => f.kind === "dns-record");
|
|
1881
|
+
const base = zone
|
|
1882
|
+
? `${zone.identity} (${records.length} record${records.length === 1 ? "" : "s"})`
|
|
1883
|
+
: `${records.length} record${records.length === 1 ? "" : "s"}`;
|
|
1884
|
+
return base + partial;
|
|
1885
|
+
}
|
|
1886
|
+
case "s3:r2": {
|
|
1887
|
+
const buckets = present.filter((f) => f.kind === "bucket").map((f) => f.identity);
|
|
1888
|
+
return (`${buckets.length} bucket${buckets.length === 1 ? "" : "s"}: ${buckets.join(", ")}` +
|
|
1889
|
+
partial);
|
|
1890
|
+
}
|
|
1891
|
+
case "github": {
|
|
1892
|
+
const repo = present.find((f) => f.kind === "repository");
|
|
1893
|
+
if (repo) {
|
|
1894
|
+
// Detail is "private · default: main · …" — pull just the
|
|
1895
|
+
// visibility (first segment) to keep the line short.
|
|
1896
|
+
const visibility = repo.detail?.split(" · ")[0];
|
|
1897
|
+
return `${repo.identity}${visibility ? chalk.dim(` (${visibility})`) : ""}`;
|
|
1898
|
+
}
|
|
1899
|
+
return present[0].identity;
|
|
1900
|
+
}
|
|
1901
|
+
case "github-pages": {
|
|
1902
|
+
const site = present.find((f) => f.kind === "page-site");
|
|
1903
|
+
if (site) {
|
|
1904
|
+
const cname = site.detail?.match(/cname:\s*([^\s·]+)/)?.[1];
|
|
1905
|
+
return cname ? `live at ${cname}` : "enabled";
|
|
1906
|
+
}
|
|
1907
|
+
return "enabled";
|
|
1908
|
+
}
|
|
1909
|
+
case "resend": {
|
|
1910
|
+
const domains = present.filter((f) => f.kind === "verified-domain").map((f) => f.identity);
|
|
1911
|
+
return domains.join(", ") + partial;
|
|
1912
|
+
}
|
|
1913
|
+
case "glitchtip":
|
|
1914
|
+
case "openpanel": {
|
|
1915
|
+
const projects = present.filter((f) => f.kind === "project").map((f) => f.identity);
|
|
1916
|
+
return projects.join(", ") + partial;
|
|
1917
|
+
}
|
|
1918
|
+
case "stripe": {
|
|
1919
|
+
const hooks = present.filter((f) => f.kind === "webhook-endpoint");
|
|
1920
|
+
return `${hooks.length} webhook${hooks.length === 1 ? "" : "s"}` + partial;
|
|
1921
|
+
}
|
|
1922
|
+
default:
|
|
1923
|
+
return present[0].identity + partial;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
/** Compact "what's not here" string for a provider where every
|
|
1927
|
+
* finding is `missing`. The detailed reason is in the per-provider
|
|
1928
|
+
* block above — this is just the headline. */
|
|
1929
|
+
function summarizeMissing(providerKey) {
|
|
1930
|
+
switch (providerKey) {
|
|
1931
|
+
case "coolify":
|
|
1932
|
+
return "no matching app";
|
|
1933
|
+
case "dns":
|
|
1934
|
+
return "no zone for this domain";
|
|
1935
|
+
case "s3:r2":
|
|
1936
|
+
return "no matching buckets";
|
|
1937
|
+
case "github":
|
|
1938
|
+
return "repo not found";
|
|
1939
|
+
case "github-pages":
|
|
1940
|
+
return "Pages not enabled";
|
|
1941
|
+
case "resend":
|
|
1942
|
+
return "domain not verified";
|
|
1943
|
+
case "glitchtip":
|
|
1944
|
+
case "openpanel":
|
|
1945
|
+
return "no matching project";
|
|
1946
|
+
case "stripe":
|
|
1947
|
+
return "no webhook for this domain";
|
|
1948
|
+
default:
|
|
1949
|
+
return "not found";
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
function providerLabel(key) {
|
|
1953
|
+
switch (key) {
|
|
1954
|
+
case "coolify":
|
|
1955
|
+
return "Coolify";
|
|
1956
|
+
case "dns":
|
|
1957
|
+
return "DNS";
|
|
1958
|
+
case "s3:r2":
|
|
1959
|
+
return "R2";
|
|
1960
|
+
case "s3:hetzner":
|
|
1961
|
+
return "Hetzner S3";
|
|
1962
|
+
case "s3:aws":
|
|
1963
|
+
return "AWS S3";
|
|
1964
|
+
case "github":
|
|
1965
|
+
return "GitHub";
|
|
1966
|
+
case "github-pages":
|
|
1967
|
+
return "Pages";
|
|
1968
|
+
case "resend":
|
|
1969
|
+
return "Resend";
|
|
1970
|
+
case "glitchtip":
|
|
1971
|
+
return "GlitchTip";
|
|
1972
|
+
case "openpanel":
|
|
1973
|
+
return "OpenPanel";
|
|
1974
|
+
case "stripe":
|
|
1975
|
+
return "Stripe";
|
|
1976
|
+
default:
|
|
1977
|
+
return key;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1522
1980
|
//# sourceMappingURL=inventory.js.map
|