run402 3.5.1 → 3.5.2

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/lib/deploy-v2.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  * UTF-8 is the default; binary files pass `"encoding": "base64"`.
23
23
  */
24
24
 
25
- import { fstatSync, readFileSync } from "node:fs";
25
+ import { existsSync, fstatSync, readFileSync } from "node:fs";
26
26
  import { resolve, dirname, isAbsolute } from "node:path";
27
27
  import {
28
28
  buildDeployResolveSummary,
@@ -646,6 +646,69 @@ async function mergeAstroReleaseSlice(spec, dirArg) {
646
646
  spec.functions = { replace: { ...existingFns, ...sliceFns } };
647
647
  }
648
648
 
649
+ /**
650
+ * Derive the list of on-disk source files a raw manifest `spec` references,
651
+ * resolved against `baseDir` (the manifest's directory, or cwd for
652
+ * --spec/stdin). Walks the file-bearing slices:
653
+ * - functions.replace[name].source
654
+ * - functions.patch.put[name].source (and functions.patch.set, an alias)
655
+ * - site.replace[key] (entry itself or entry.source)
656
+ * - site.patch.put[key] (entry itself or entry.source)
657
+ *
658
+ * A manifest file entry is `{ path }` (on-disk ref) OR `{ data, encoding? }`
659
+ * (inline bytes). Only `{ path }` entries with no `data` resolve to a disk
660
+ * file; inline entries are skipped (nothing to scan on disk). Returns a
661
+ * de-duped list of absolute paths. Existence + extension filtering is the
662
+ * caller's job (GH-409). Defensive: tolerates missing/odd shapes silently —
663
+ * the manifest normalizer is the authority on shape validity, not this
664
+ * best-effort extractor.
665
+ */
666
+ function collectManifestSourceFiles(spec, baseDir) {
667
+ const out = new Set();
668
+ if (!spec || typeof spec !== "object") return [];
669
+
670
+ const resolveEntryPath = (entry) => {
671
+ // The on-disk ref can be the entry itself (`{ path }`) or nested under
672
+ // `.source` (function entries, asset put entries). Skip inline `{ data }`.
673
+ const node =
674
+ entry && typeof entry === "object" && entry.source && typeof entry.source === "object"
675
+ ? entry.source
676
+ : entry;
677
+ if (
678
+ node &&
679
+ typeof node === "object" &&
680
+ typeof node.path === "string" &&
681
+ node.data === undefined
682
+ ) {
683
+ out.add(isAbsolute(node.path) ? node.path : resolve(baseDir, node.path));
684
+ }
685
+ };
686
+
687
+ const eachValue = (map) => {
688
+ if (!map || typeof map !== "object") return;
689
+ for (const entry of Object.values(map)) resolveEntryPath(entry);
690
+ };
691
+
692
+ const fns = spec.functions;
693
+ if (fns && typeof fns === "object") {
694
+ eachValue(fns.replace);
695
+ if (fns.patch && typeof fns.patch === "object") {
696
+ eachValue(fns.patch.put);
697
+ eachValue(fns.patch.set);
698
+ }
699
+ }
700
+
701
+ const site = spec.site;
702
+ if (site && typeof site === "object") {
703
+ eachValue(site.replace);
704
+ if (site.patch && typeof site.patch === "object") {
705
+ eachValue(site.patch.put);
706
+ }
707
+ }
708
+
709
+ return [...out];
710
+ }
711
+
649
712
  async function applyCmd(args) {
650
713
  const opts = parseApplyArgs(args);
651
714
  const { source, error: sourceError } = resolveApplySource(opts, hasStdinSource());
@@ -772,15 +835,35 @@ async function applyCmd(args) {
772
835
  // RUN402_DEPLOY_SKIP_SCAN=1 — useful for forcing a deploy when
773
836
  // the scanner has a false positive that the operator has confirmed
774
837
  // is fine. Hits with severity `error` fail the deploy.
838
+ //
839
+ // Scope (GH-409): a `--dir` (Astro SSR build) deploy walks that dir —
840
+ // it IS the artifact. A manifest/spec/stdin deploy scans ONLY the
841
+ // on-disk source files the manifest actually references, resolved
842
+ // against the manifest's baseDir. We must NOT walk cwd/src for a
843
+ // manifest deploy: running from inside an unrelated source tree (e.g.
844
+ // the gateway monorepo, which legitimately has dozens of `getUser`
845
+ // references) would otherwise block a deploy of one unrelated HTML file.
775
846
  if (process.env.RUN402_DEPLOY_SKIP_SCAN !== "1") {
776
847
  try {
777
- const { resolveScanRoot, scanSourceTree, SCAN_SEVERITY } = await import(
848
+ const { scanSourceTree, scanSourceFiles, SCAN_SEVERITY } = await import(
778
849
  "./doctor-source-scan.mjs"
779
850
  );
780
- const scanRoot = opts.dir
781
- ? (isAbsolute(opts.dir) ? opts.dir : resolve(process.cwd(), opts.dir))
782
- : resolveScanRoot(process.cwd());
783
- const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
851
+ const scannableExt = /\.(?:ts|tsx|js|jsx|mjs|cjs|astro)$/;
852
+ let findings;
853
+ if (opts.dir) {
854
+ const scanRoot = isAbsolute(opts.dir)
855
+ ? opts.dir
856
+ : resolve(process.cwd(), opts.dir);
857
+ findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
858
+ } else {
859
+ const baseDir = manifestPath ? dirname(manifestPath) : process.cwd();
860
+ const files = collectManifestSourceFiles(spec, baseDir).filter(
861
+ (p) => scannableExt.test(p) && existsSync(p),
862
+ );
863
+ findings = files.length > 0
864
+ ? scanSourceFiles(files, { cwd: process.cwd() })
865
+ : [];
866
+ }
784
867
  const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
785
868
  if (errorFindings.length > 0) {
786
869
  const summary = errorFindings.slice(0, 10).map((f) => {
@@ -422,6 +422,47 @@ export function scanSourceTree(srcDir, opts = {}) {
422
422
  return findings;
423
423
  }
424
424
 
425
+ /** Scan an explicit list of on-disk file paths — no directory walk.
426
+ * Used by `run402 deploy apply` for manifest/spec/stdin deploys, where
427
+ * the artifact is exactly the set of files the manifest references, NOT
428
+ * whatever happens to live under cwd/src (GH-409). Files without a
429
+ * scannable extension are ignored; unreadable files become a WARN
430
+ * finding (never throw). Returns the combined findings list, sorted by
431
+ * file + line for stable output, exactly like `scanSourceTree`. */
432
+ export function scanSourceFiles(filePaths, opts = {}) {
433
+ const findings = [];
434
+ const cwd = opts.cwd ?? process.cwd();
435
+ // Same capability picture as scanSourceTree (#8 mint check).
436
+ const declaredCapabilities =
437
+ opts.declaredCapabilities ?? readDeclaredCapabilities(cwd);
438
+ for (const filePath of filePaths) {
439
+ if (!SCANNED_EXTENSIONS.has(extname(filePath))) continue;
440
+ let content;
441
+ try {
442
+ content = readFileSync(filePath, "utf8");
443
+ } catch (err) {
444
+ findings.push({
445
+ code: "R402_AUTH_SOURCE_SCAN_ERROR",
446
+ severity: SCAN_SEVERITY.WARN,
447
+ file: relative(cwd, filePath),
448
+ message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
449
+ });
450
+ continue;
451
+ }
452
+ findings.push(
453
+ ...scanFileContent(content, {
454
+ filePath: relative(cwd, filePath),
455
+ declaredCapabilities,
456
+ }),
457
+ );
458
+ }
459
+ findings.sort((a, b) => {
460
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
461
+ return (a.line ?? 0) - (b.line ?? 0);
462
+ });
463
+ return findings;
464
+ }
465
+
425
466
  function walk(dir, visitor) {
426
467
  let entries;
427
468
  try {
@@ -7,6 +7,7 @@ import { join } from "node:path";
7
7
  import {
8
8
  scanFileContent,
9
9
  scanSourceTree,
10
+ scanSourceFiles,
10
11
  readDeclaredCapabilities,
11
12
  SCAN_SEVERITY,
12
13
  _testOnly_hallucinatedNames,
@@ -415,3 +416,106 @@ describe("readDeclaredCapabilities — run402.config.json capability union", ()
415
416
  );
416
417
  });
417
418
  });
419
+
420
+ describe("scanSourceFiles — explicit file list (GH-409)", () => {
421
+ function makeTwoFileDir() {
422
+ const dir = mkdtempSync(join(tmpdir(), "r402-gh409-"));
423
+ // A clean file referenced by a manifest — no auth violations.
424
+ writeFileSync(join(dir, "clean.ts"), "export const x = 1;\n");
425
+ // A sibling that legitimately imports a hallucinated name (e.g. the
426
+ // gateway source tree implementing the auth surface). NOT in the
427
+ // manifest, so it must never be scanned for a manifest-based deploy.
428
+ writeFileSync(
429
+ join(dir, "unrelated.ts"),
430
+ 'import { getUser } from "@run402/functions";\n',
431
+ );
432
+ return dir;
433
+ }
434
+
435
+ it("scanSourceTree (OLD behavior) WOULD flag the unrelated sibling — proving the bug", () => {
436
+ const dir = makeTwoFileDir();
437
+ const findings = scanSourceTree(dir, { cwd: dir });
438
+ const errs = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
439
+ assert.ok(
440
+ errs.some((f) => f.file === "unrelated.ts" && f.attempted_name === "getUser"),
441
+ "whole-tree scan should have surfaced the unrelated sibling (this is the bug)",
442
+ );
443
+ });
444
+
445
+ it("scanSourceFiles([clean.ts]) returns ZERO error findings — GH-409 regression", () => {
446
+ const dir = makeTwoFileDir();
447
+ const findings = scanSourceFiles([join(dir, "clean.ts")], { cwd: dir });
448
+ const errs = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
449
+ assert.equal(
450
+ errs.length,
451
+ 0,
452
+ `scoped scan must not walk the sibling; got: ${JSON.stringify(errs)}`,
453
+ );
454
+ });
455
+
456
+ it("scanSourceFiles([unrelated.ts]) STILL flags a file that IS in scope", () => {
457
+ const dir = makeTwoFileDir();
458
+ const findings = scanSourceFiles([join(dir, "unrelated.ts")], { cwd: dir });
459
+ const f = findings.find(
460
+ (x) => x.attempted_name === "getUser" && x.code === "R402_AUTH_UNKNOWN_EXPORT",
461
+ );
462
+ assert.ok(f, "in-scope file with a violation must still be flagged");
463
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
464
+ assert.equal(f.file, "unrelated.ts");
465
+ });
466
+
467
+ it("labels findings relative to opts.cwd (mirroring scanSourceTree)", () => {
468
+ const dir = mkdtempSync(join(tmpdir(), "r402-gh409-rel-"));
469
+ mkdirSync(join(dir, "functions"));
470
+ const abs = join(dir, "functions", "login.ts");
471
+ writeFileSync(abs, 'import { getSession } from "@run402/functions";\n');
472
+ const findings = scanSourceFiles([abs], { cwd: dir });
473
+ const f = findings.find((x) => x.attempted_name === "getSession");
474
+ assert.ok(f);
475
+ assert.equal(f.file, join("functions", "login.ts"));
476
+ });
477
+
478
+ it("skips files without a scannable extension", () => {
479
+ const dir = mkdtempSync(join(tmpdir(), "r402-gh409-ext-"));
480
+ const html = join(dir, "index.html");
481
+ writeFileSync(html, '<script>const u = getSession();</script>\n');
482
+ const findings = scanSourceFiles([html], { cwd: dir });
483
+ assert.equal(findings.length, 0, ".html is not a scanned extension");
484
+ });
485
+
486
+ it("returns no findings for an empty file list", () => {
487
+ assert.deepEqual(scanSourceFiles([], { cwd: tmpdir() }), []);
488
+ });
489
+
490
+ it("emits a warn finding for an unreadable (missing) file, never throws", () => {
491
+ const dir = mkdtempSync(join(tmpdir(), "r402-gh409-missing-"));
492
+ const missing = join(dir, "ghost.ts");
493
+ const findings = scanSourceFiles([missing], { cwd: dir });
494
+ const f = findings.find((x) => x.code === "R402_AUTH_SOURCE_SCAN_ERROR");
495
+ assert.ok(f, "unreadable file should produce a warn finding");
496
+ assert.equal(f.severity, SCAN_SEVERITY.WARN);
497
+ assert.equal(f.file, "ghost.ts");
498
+ });
499
+
500
+ it("respects opts.declaredCapabilities for the mint warning", () => {
501
+ const dir = mkdtempSync(join(tmpdir(), "r402-gh409-cap-"));
502
+ const abs = join(dir, "login.ts");
503
+ writeFileSync(
504
+ abs,
505
+ "export default async () => auth.sessions.createResponseFromTenantAssertion({});\n",
506
+ );
507
+ const withCap = scanSourceFiles([abs], {
508
+ cwd: dir,
509
+ declaredCapabilities: ["auth.sessionMint"],
510
+ });
511
+ assert.ok(
512
+ !withCap.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
513
+ "declared capability should suppress the mint warning",
514
+ );
515
+ const without = scanSourceFiles([abs], { cwd: dir });
516
+ assert.ok(
517
+ without.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
518
+ "absent capability should surface the mint warning",
519
+ );
520
+ });
521
+ });
@@ -94,9 +94,9 @@ export async function runInitAstro(args = []) {
94
94
  preview: "astro preview",
95
95
  },
96
96
  dependencies: {
97
- astro: "^5.0.0",
98
- "@run402/astro": "^1.0.0",
99
- "@run402/functions": "^2.5.0",
97
+ astro: "^7.0.0",
98
+ "@run402/astro": "^2.4.2",
99
+ "@run402/functions": "^3.0.0",
100
100
  },
101
101
  },
102
102
  null,
@@ -108,7 +108,7 @@ export async function runInitAstro(args = []) {
108
108
  content: `// Run402 Astro preset. One config line gets you:
109
109
  // - SSR on Lambda with SnapStart
110
110
  // - ISR cache with cache.invalidate() admin-edit visibility
111
- // - AsyncLocalStorage context so db()/getUser()/cache.* work natively
111
+ // - AsyncLocalStorage context so db()/auth.* helpers work natively
112
112
  // - Build-time detectors for unsupported Astro features
113
113
  import run402 from "@run402/astro";
114
114
  export default run402();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {