naro-cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ANALYTICS_FILTER_KEYS,
3
4
  BACKEND_STEPS,
4
5
  BOOKMARK_FORMAT_MESSAGE,
5
6
  INDEX_SQL,
@@ -13,6 +14,7 @@ import {
13
14
  RESET_BACKEND_OUTDATED,
14
15
  RESTORE_OUTCOME_UNKNOWN_CODES,
15
16
  SCHEMA_SQL,
17
+ SPEED_INSIGHTS_FILTER_KEYS,
16
18
  SPEED_INSIGHTS_METRICS,
17
19
  SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
18
20
  STORAGE_OBJECTS_TABLE,
@@ -81,7 +83,7 @@ import {
81
83
  toNaroError,
82
84
  unreadablePolicyComment,
83
85
  writeProjectLink
84
- } from "./chunk-O4NJRDJE.js";
86
+ } from "./chunk-6WJ3QQ5Y.js";
85
87
  import "./chunk-K3NQKI34.js";
86
88
 
87
89
  // src/args.ts
@@ -139,6 +141,12 @@ var STRING_FLAGS = {
139
141
  "--metric": "metric",
140
142
  "--range": "range",
141
143
  "--route": "route",
144
+ "--path": "path",
145
+ "--referrer": "referrer",
146
+ "--country": "country",
147
+ "--device": "device",
148
+ "--browser": "browser",
149
+ "--os": "os",
142
150
  "--file": "file",
143
151
  "--output": "output",
144
152
  "--lang": "lang",
@@ -490,86 +498,9 @@ import { readFileSync as readFileSync5 } from "fs";
490
498
  import { dirname as dirname4, join as join7 } from "path";
491
499
  import { fileURLToPath } from "url";
492
500
 
493
- // src/commands/auth.ts
494
- async function runLogin(context) {
495
- const { output } = context;
496
- output.log("Opening your browser to sign in with GitHub...");
497
- const session = await login({
498
- openBrowser: (url) => {
499
- output.log(`If your browser did not open, visit:
500
- ${url}`);
501
- openBrowser(url);
502
- }
503
- });
504
- saveSession(session);
505
- output.result(
506
- {
507
- user: {
508
- id: session.user.id,
509
- email: session.user.email,
510
- github: session.user.github
511
- }
512
- },
513
- [`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
514
- );
515
- }
516
- async function runLogout(context) {
517
- const session = loadStoredSession();
518
- if (session) {
519
- try {
520
- const client = createAuthClient();
521
- await client.auth.setSession({
522
- access_token: session.access_token,
523
- refresh_token: session.refresh_token
524
- });
525
- await client.auth.signOut();
526
- } catch {
527
- }
528
- }
529
- clearSession();
530
- context.output.result({ loggedOut: true }, ["Logged out."]);
531
- }
532
- async function currentSession() {
533
- let session = loadStoredSession();
534
- if (!session)
535
- throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
536
- if (isExpired(session)) {
537
- try {
538
- session = await refreshStoredSession(session.refresh_token);
539
- saveSession(session);
540
- } catch {
541
- throw new NaroError(
542
- "NOT_LOGGED_IN",
543
- "Session expired. Run `naro login`."
544
- );
545
- }
546
- }
547
- return session;
548
- }
549
- async function runWhoami(context) {
550
- const session = await currentSession();
551
- const handle = session.user.github ? ` (${session.user.github})` : "";
552
- context.output.result(
553
- {
554
- user: {
555
- id: session.user.id,
556
- email: session.user.email,
557
- github: session.user.github
558
- }
559
- },
560
- [`Logged in as ${session.user.email ?? session.user.id}${handle}`]
561
- );
562
- }
563
- async function runToken(context) {
564
- const session = await currentSession();
565
- process.stderr.write(
566
- "\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
567
- );
568
- context.output.result(
569
- { token: session.access_token, expiresAt: session.expires_at },
570
- [session.access_token]
571
- );
572
- }
501
+ // src/commands/migration.ts
502
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
503
+ import { join, relative } from "path";
573
504
 
574
505
  // src/commands/confirm.ts
575
506
  function cannotAsk(context) {
@@ -588,8 +519,6 @@ async function confirmOrAbort(context, question) {
588
519
  }
589
520
 
590
521
  // src/commands/migration.ts
591
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
592
- import { join, relative } from "path";
593
522
  var processStdin = {
594
523
  stdinIsTty: () => process.stdin.isTTY === true,
595
524
  readStdin
@@ -763,63 +692,438 @@ async function runMigrationRepair(context) {
763
692
  context,
764
693
  status === "applied" ? `Mark ${key} as applied in the project database's ledger? Its SQL is not run.` : `Remove ${key} from the project database's ledger? Its SQL is not rolled back.`
765
694
  );
766
- if (status === "applied" && ledger.length === 0)
767
- await client.db.query(projectId, { sql: LEDGER_DDL });
768
- const answer = await client.db.query(projectId, {
769
- sql: status === "applied" ? LEDGER_INSERT : LEDGER_DELETE,
770
- params: status === "applied" ? [key, key] : [key, withoutSuffix(key)]
771
- });
772
- const changed = (answer.statements[0]?.meta.changes ?? 0) > 0;
773
- if (!changed) {
774
- context.output.warn(nothingToDo);
775
- context.output.result({ name: key, status, changed: false });
776
- return;
777
- }
778
- context.output.result({ name: key, status, changed: true }, [
779
- status === "applied" ? `\u2713 Marked ${key} as applied.` : `\u2713 Removed ${key} from the ledger.`
780
- ]);
695
+ if (status === "applied" && ledger.length === 0)
696
+ await client.db.query(projectId, { sql: LEDGER_DDL });
697
+ const answer = await client.db.query(projectId, {
698
+ sql: status === "applied" ? LEDGER_INSERT : LEDGER_DELETE,
699
+ params: status === "applied" ? [key, key] : [key, withoutSuffix(key)]
700
+ });
701
+ const changed = (answer.statements[0]?.meta.changes ?? 0) > 0;
702
+ if (!changed) {
703
+ context.output.warn(nothingToDo);
704
+ context.output.result({ name: key, status, changed: false });
705
+ return;
706
+ }
707
+ context.output.result({ name: key, status, changed: true }, [
708
+ status === "applied" ? `\u2713 Marked ${key} as applied.` : `\u2713 Removed ${key} from the ledger.`
709
+ ]);
710
+ }
711
+ async function runMigrationUp(context) {
712
+ const client = await context.client();
713
+ const projectId = await context.resolveProjectId();
714
+ const dir = migrationsDir(context);
715
+ const local = listMigrationFiles(dir);
716
+ const ledger = await appliedMigrations(client, projectId);
717
+ const pending = compareMigrations(local, ledger).filter((row) => row.status === "pending").map((row) => row.name);
718
+ if (pending.length === 0) {
719
+ context.output.result({ projectId, applied: [], pending: [] }, [
720
+ "No pending migrations."
721
+ ]);
722
+ return;
723
+ }
724
+ if (context.flags.dryRun) {
725
+ const problems = policyProblems(dir, pending);
726
+ context.output.result({ projectId, dryRun: true, pending, problems }, [
727
+ `Pending migrations (${String(pending.length)}):`,
728
+ ...pending.map((name) => ` ${printable(name)}`),
729
+ ...problems.map(
730
+ (problem) => `${printable(problem.migration)}: ${printable(problem.message)}`
731
+ )
732
+ ]);
733
+ if (problems.length > 0) process.exitCode = 1;
734
+ return;
735
+ }
736
+ context.output.log(`Pending migrations (${String(pending.length)}):`);
737
+ for (const name of pending) context.output.log(` ${printable(name)}`);
738
+ await confirmOrAbort(
739
+ context,
740
+ `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
741
+ );
742
+ const { applied, warnings } = await applyMigrationFiles(
743
+ context,
744
+ client,
745
+ projectId,
746
+ dir,
747
+ pending
748
+ );
749
+ context.output.result({ projectId, applied, pending: [], warnings }, [
750
+ `\u2705 Applied ${String(applied.length)} migration(s).`
751
+ ]);
752
+ }
753
+
754
+ // src/commands/filters.ts
755
+ function filtersOf(flags, keys) {
756
+ const filters = {};
757
+ for (const key of keys) {
758
+ const value = flags[key];
759
+ if (value !== void 0) filters[key] = value;
760
+ }
761
+ return filters;
762
+ }
763
+ function givenFilterFlag(flags) {
764
+ return ANALYTICS_FILTER_KEYS.find((key) => {
765
+ const value = flags[key];
766
+ return value !== void 0 && value !== "";
767
+ });
768
+ }
769
+ function filteredBy(filters) {
770
+ const applied = ANALYTICS_FILTER_KEYS.flatMap((key) => {
771
+ const value = filters[key];
772
+ return value ? [`${key}=${printable(value)}`] : [];
773
+ });
774
+ return applied.length === 0 ? "" : ` \xB7 filtered by ${applied.join(", ")}`;
775
+ }
776
+
777
+ // src/commands/insights-format.ts
778
+ var OFF_LINE = "Speed Insights is off for this project. Turn it on: naro insights enable";
779
+ var TURNED_ON = "Speed Insights is on. Measuring starts within about a minute; values arrive when visitors leave a page.";
780
+ var TURNED_OFF = "Speed Insights is off. Measuring stops within about a minute; turning it back on shows past measurements again.";
781
+ var RANGE_TEXT = {
782
+ "24h": "last 24 hours",
783
+ "7d": "last 7 days",
784
+ "30d": "last 30 days"
785
+ };
786
+ var PART_TEXT = {
787
+ ttfb: "TTFB",
788
+ loadDelay: "load delay",
789
+ loadDuration: "load duration",
790
+ renderDelay: "render delay",
791
+ inputDelay: "input delay",
792
+ processing: "processing",
793
+ presentation: "presentation"
794
+ };
795
+ var PAGE_LOAD_PART_TEXT = {
796
+ wait: "wait",
797
+ dns: "DNS",
798
+ connect: "connect",
799
+ server: "server",
800
+ download: "download",
801
+ processing: "processing",
802
+ onload: "onload"
803
+ };
804
+ function rangeText(range) {
805
+ const known = RANGE_TEXT;
806
+ return known[range] ?? printable(range);
807
+ }
808
+ function partText(name) {
809
+ const known = PART_TEXT;
810
+ return known[name] ?? printable(name);
811
+ }
812
+ function pageLoadPartText(name) {
813
+ const known = PAGE_LOAD_PART_TEXT;
814
+ return known[name] ?? printable(name);
815
+ }
816
+ var ATTRIBUTED = /* @__PURE__ */ new Set([
817
+ "lcp",
818
+ "inp",
819
+ "cls"
820
+ ]);
821
+ var DEPLOYMENT_ROWS = 10;
822
+ var SUBJECT_LENGTH = 50;
823
+ function formatMs(ms) {
824
+ const rounded = Math.round(ms);
825
+ return rounded < 1e3 ? `${String(rounded)} ms` : `${(rounded / 1e3).toFixed(2)} s`;
826
+ }
827
+ function formatValue(metric, value) {
828
+ return metric === "cls" ? value.toFixed(2) : formatMs(value);
829
+ }
830
+ function formatChange(metric, change2) {
831
+ const size = formatValue(metric, Math.abs(change2));
832
+ if (Number.parseFloat(size) === 0) return "\xB10";
833
+ return change2 > 0 ? `\u25B2 +${size}` : `\u25BC \u2212${size}`;
834
+ }
835
+ function formatCount(count2) {
836
+ return Math.round(count2).toLocaleString("en-US");
837
+ }
838
+ function percent(part, total) {
839
+ return `${String(Math.round(part / total * 100))}%`;
840
+ }
841
+ function label(metric) {
842
+ return metric.toUpperCase();
843
+ }
844
+ function environmentSuffix(environment) {
845
+ return environment === "preview" ? ", preview" : "";
846
+ }
847
+ function environmentWord(environment) {
848
+ return environment === "preview" ? "preview " : "";
849
+ }
850
+ function table(rows) {
851
+ const widths = [];
852
+ for (const row of rows)
853
+ row.forEach((cell, i) => {
854
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
855
+ });
856
+ return rows.map(
857
+ (row) => ` ${row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.trimEnd()
858
+ );
859
+ }
860
+ function summaryLines(report) {
861
+ return table([
862
+ ["Metric", "p75", "Good", "Needs improvement", "Poor", "Visits"],
863
+ ...SPEED_INSIGHTS_METRICS.map((metric) => {
864
+ const summary = report.summary[metric];
865
+ if (summary.p75 === null || summary.samples <= 0)
866
+ return [label(metric), "\u2014", "\u2014", "\u2014", "\u2014", "0"];
867
+ return [
868
+ label(metric),
869
+ formatValue(metric, summary.p75),
870
+ percent(summary.good, summary.samples),
871
+ percent(summary.needsImprovement, summary.samples),
872
+ percent(summary.poor, summary.samples),
873
+ formatCount(summary.samples)
874
+ ];
875
+ })
876
+ ]);
877
+ }
878
+ function pageLoadLine(pageLoad) {
879
+ if (!pageLoad || pageLoad.samples <= 0 || pageLoad.p75 === null) return null;
880
+ const head = `Page load p75 ${formatMs(pageLoad.p75)}`;
881
+ if (pageLoad.parts.length === 0) return head;
882
+ const steps = pageLoad.parts.map(
883
+ (part) => `${pageLoadPartText(part.name)} ${formatMs(part.ms)}`
884
+ );
885
+ return `${head} \xB7 mean per step: ${steps.join(" \xB7 ")}`;
886
+ }
887
+ function breakdownTable(heading, metric, rows) {
888
+ if (rows.length === 0) return [" (none)"];
889
+ return table([
890
+ ["p75", "Visits", heading],
891
+ ...rows.map((row) => [
892
+ formatValue(metric, row.p75),
893
+ formatCount(row.samples),
894
+ printable(row.name)
895
+ ])
896
+ ]);
897
+ }
898
+ function elementLines(metric, elements) {
899
+ const lines = ["", `${label(metric)} elements behind slow visits`];
900
+ if (elements.length === 0) return [...lines, " (none)"];
901
+ for (const row of elements) {
902
+ lines.push(
903
+ ` ${printable(row.element)} on ${printable(row.route)}: ${formatCount(row.slow)} slow visits, average ${formatValue(metric, row.average)}`
904
+ );
905
+ if (row.parts && row.parts.length > 0)
906
+ lines.push(
907
+ ` ${row.parts.map((part) => `${partText(part.name)} ${formatMs(part.ms)}`).join(" \xB7 ")}`
908
+ );
909
+ if (row.url) lines.push(` image ${printable(row.url)}`);
910
+ }
911
+ return lines;
912
+ }
913
+ function deploymentName(row) {
914
+ if (row.source === "cli" || !row.commitSha) return row.id.slice(0, 8);
915
+ const sha = printable(row.commitSha.slice(0, 7));
916
+ const subject = [
917
+ ...printable(row.commitMessage?.split("\n")[0]?.trim() ?? "")
918
+ ];
919
+ if (subject.length === 0) return sha;
920
+ return subject.length > SUBJECT_LENGTH ? `${sha} ${subject.slice(0, SUBJECT_LENGTH - 1).join("")}\u2026` : `${sha} ${subject.join("")}`;
921
+ }
922
+ function changeText(metric, row) {
923
+ if (row.samples < SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES)
924
+ return "too few visits";
925
+ if (row.change === null) return "\u2013";
926
+ return formatChange(metric, row.change);
927
+ }
928
+ function deploymentLines(metric, deployments, environment) {
929
+ if (deployments === null) return [];
930
+ const lines = [
931
+ "",
932
+ `${label(metric)} by ${environmentWord(environment)}deployment, newest first`
933
+ ];
934
+ const measured = deployments.filter((row) => row.samples > 0).slice(0, DEPLOYMENT_ROWS);
935
+ if (measured.length === 0) return [...lines, " (none)"];
936
+ return [
937
+ ...lines,
938
+ ...table([
939
+ ["", "p75", "Visits", "Change", "Deployment"],
940
+ ...measured.map((row) => [
941
+ row.liveUntil === null ? "Live" : "",
942
+ row.p75 === null ? "\u2014" : formatValue(metric, row.p75),
943
+ formatCount(row.samples),
944
+ changeText(metric, row),
945
+ deploymentName(row)
946
+ ])
947
+ ])
948
+ ];
949
+ }
950
+ function reportLines(report, deployments, filters = {}) {
951
+ if (!report.enabled) return [OFF_LINE];
952
+ const { metric, range, environment } = report;
953
+ const filtered = filteredBy(filters);
954
+ const head = `Speed Insights \u2014 ${label(metric)}, ${rangeText(range)}${environmentSuffix(environment)}${filtered}`;
955
+ if (SPEED_INSIGHTS_METRICS.every((m) => report.summary[m].samples <= 0))
956
+ return [
957
+ head,
958
+ "",
959
+ // The dashboard's noFilteredSamples: waiting is the wrong advice when a
960
+ // filter is what emptied the report.
961
+ filtered === "" ? `No ${environmentWord(environment)}measurements in the ${rangeText(range)} yet. Values arrive when visitors leave a page.` : "No measurements match these filters. Drop a filter to see more."
962
+ ];
963
+ const pageLoad = pageLoadLine(report.pageLoad);
964
+ return [
965
+ head,
966
+ "",
967
+ ...summaryLines(report),
968
+ ...pageLoad === null ? [] : ["", pageLoad],
969
+ "",
970
+ `${label(metric)} by page`,
971
+ ...breakdownTable("Page", metric, report.paths),
972
+ "",
973
+ `${label(metric)} by device`,
974
+ ...breakdownTable("Device", metric, report.devices),
975
+ ...ATTRIBUTED.has(metric) ? elementLines(metric, report.elements) : [],
976
+ ...deploymentLines(metric, deployments, environment)
977
+ ];
978
+ }
979
+ function routePathsLines(result, filters = {}) {
980
+ return [
981
+ `${label(result.metric)} p75 under ${printable(result.route)}, ${rangeText(result.range)}${environmentSuffix(result.environment)}${filteredBy(filters)}`,
982
+ ...breakdownTable("Path", result.metric, result.paths)
983
+ ];
984
+ }
985
+
986
+ // src/commands/analytics-format.ts
987
+ var TABLES = [
988
+ { key: "pages", title: "Pages", column: "Page" },
989
+ { key: "referrers", title: "Referrers", column: "Referrer" },
990
+ { key: "countries", title: "Countries", column: "Country" },
991
+ { key: "devices", title: "Devices", column: "Device" },
992
+ { key: "browsers", title: "Browsers", column: "Browser" },
993
+ { key: "os", title: "Operating systems", column: "Operating system" }
994
+ ];
995
+ function breakdownLines({ title, column }, rows) {
996
+ if (rows.length === 0) return [title, " No data yet."];
997
+ return [
998
+ title,
999
+ ...table([
1000
+ ["Page views", column],
1001
+ // The name last: free text is never padded against anything after it.
1002
+ ...rows.map((row) => [formatCount(row.views), printable(row.name)])
1003
+ ])
1004
+ ];
1005
+ }
1006
+ function analyticsLines(report, range, filters) {
1007
+ const filtered = filteredBy(filters);
1008
+ const head = `Visitors ${formatCount(report.totals.visitors)} \xB7 Page views ${formatCount(report.totals.views)} \xB7 last ${printable(range)}${filtered}`;
1009
+ if (filtered !== "" && report.totals.views === 0)
1010
+ return [
1011
+ head,
1012
+ "",
1013
+ "No visits match these filters. Drop a filter to see more."
1014
+ ];
1015
+ return [
1016
+ head,
1017
+ ...TABLES.flatMap((spec) => [
1018
+ "",
1019
+ ...breakdownLines(spec, report[spec.key])
1020
+ ])
1021
+ ];
1022
+ }
1023
+
1024
+ // src/commands/analytics.ts
1025
+ function queryOf(flags) {
1026
+ const filters = filtersOf(flags, ANALYTICS_FILTER_KEYS);
1027
+ return {
1028
+ ...flags.range !== void 0 ? { range: flags.range } : {},
1029
+ ...Object.keys(filters).length > 0 ? { filters } : {}
1030
+ };
1031
+ }
1032
+ async function runAnalytics(context) {
1033
+ if (context.flags.env !== void 0)
1034
+ throw new NaroError(
1035
+ "VALIDATION_FAILED",
1036
+ "Analytics reads the production worker only; drop --env."
1037
+ );
1038
+ const client = await context.client();
1039
+ const projectId = await context.resolveProjectId();
1040
+ const query = queryOf(context.flags);
1041
+ const report = await client.analytics.report(projectId, query);
1042
+ context.output.result(
1043
+ { projectId, ...report },
1044
+ analyticsLines(report, report.range, query.filters ?? {})
1045
+ );
1046
+ }
1047
+
1048
+ // src/commands/auth.ts
1049
+ async function runLogin(context) {
1050
+ const { output } = context;
1051
+ output.log("Opening your browser to sign in with GitHub...");
1052
+ const session = await login({
1053
+ openBrowser: (url) => {
1054
+ output.log(`If your browser did not open, visit:
1055
+ ${url}`);
1056
+ openBrowser(url);
1057
+ }
1058
+ });
1059
+ saveSession(session);
1060
+ output.result(
1061
+ {
1062
+ user: {
1063
+ id: session.user.id,
1064
+ email: session.user.email,
1065
+ github: session.user.github
1066
+ }
1067
+ },
1068
+ [`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
1069
+ );
1070
+ }
1071
+ async function runLogout(context) {
1072
+ const session = loadStoredSession();
1073
+ if (session) {
1074
+ try {
1075
+ const client = createAuthClient();
1076
+ await client.auth.setSession({
1077
+ access_token: session.access_token,
1078
+ refresh_token: session.refresh_token
1079
+ });
1080
+ await client.auth.signOut();
1081
+ } catch {
1082
+ }
1083
+ }
1084
+ clearSession();
1085
+ context.output.result({ loggedOut: true }, ["Logged out."]);
1086
+ }
1087
+ async function currentSession() {
1088
+ let session = loadStoredSession();
1089
+ if (!session)
1090
+ throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
1091
+ if (isExpired(session)) {
1092
+ try {
1093
+ session = await refreshStoredSession(session.refresh_token);
1094
+ saveSession(session);
1095
+ } catch {
1096
+ throw new NaroError(
1097
+ "NOT_LOGGED_IN",
1098
+ "Session expired. Run `naro login`."
1099
+ );
1100
+ }
1101
+ }
1102
+ return session;
1103
+ }
1104
+ async function runWhoami(context) {
1105
+ const session = await currentSession();
1106
+ const handle = session.user.github ? ` (${session.user.github})` : "";
1107
+ context.output.result(
1108
+ {
1109
+ user: {
1110
+ id: session.user.id,
1111
+ email: session.user.email,
1112
+ github: session.user.github
1113
+ }
1114
+ },
1115
+ [`Logged in as ${session.user.email ?? session.user.id}${handle}`]
1116
+ );
781
1117
  }
782
- async function runMigrationUp(context) {
783
- const client = await context.client();
784
- const projectId = await context.resolveProjectId();
785
- const dir = migrationsDir(context);
786
- const local = listMigrationFiles(dir);
787
- const ledger = await appliedMigrations(client, projectId);
788
- const pending = compareMigrations(local, ledger).filter((row) => row.status === "pending").map((row) => row.name);
789
- if (pending.length === 0) {
790
- context.output.result({ projectId, applied: [], pending: [] }, [
791
- "No pending migrations."
792
- ]);
793
- return;
794
- }
795
- if (context.flags.dryRun) {
796
- const problems = policyProblems(dir, pending);
797
- context.output.result({ projectId, dryRun: true, pending, problems }, [
798
- `Pending migrations (${String(pending.length)}):`,
799
- ...pending.map((name) => ` ${printable(name)}`),
800
- ...problems.map(
801
- (problem) => `${printable(problem.migration)}: ${printable(problem.message)}`
802
- )
803
- ]);
804
- if (problems.length > 0) process.exitCode = 1;
805
- return;
806
- }
807
- context.output.log(`Pending migrations (${String(pending.length)}):`);
808
- for (const name of pending) context.output.log(` ${printable(name)}`);
809
- await confirmOrAbort(
810
- context,
811
- `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
1118
+ async function runToken(context) {
1119
+ const session = await currentSession();
1120
+ process.stderr.write(
1121
+ "\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
812
1122
  );
813
- const { applied, warnings } = await applyMigrationFiles(
814
- context,
815
- client,
816
- projectId,
817
- dir,
818
- pending
1123
+ context.output.result(
1124
+ { token: session.access_token, expiresAt: session.expires_at },
1125
+ [session.access_token]
819
1126
  );
820
- context.output.result({ projectId, applied, pending: [], warnings }, [
821
- `\u2705 Applied ${String(applied.length)} migration(s).`
822
- ]);
823
1127
  }
824
1128
 
825
1129
  // src/commands/backend.ts
@@ -1455,7 +1759,7 @@ async function allUsers(client, projectId) {
1455
1759
  function orDash(value) {
1456
1760
  return value === null ? "-" : printable(value);
1457
1761
  }
1458
- function label(user) {
1762
+ function label2(user) {
1459
1763
  return printable(user.username ?? user.email ?? user.id);
1460
1764
  }
1461
1765
  function userTable(users) {
@@ -1537,7 +1841,7 @@ async function runBackendUsersCreate(context, input = processStdin3) {
1537
1841
  );
1538
1842
  const signIn = user.username === null ? "naro.auth.signIn({ email, password })" : `naro.auth.signIn({ username: ${JSON.stringify(printable(user.username))}, password })`;
1539
1843
  context.output.result({ projectId, user }, [
1540
- `\u2705 Created user ${label(user)}. It signs in at once with ${signIn}.`,
1844
+ `\u2705 Created user ${label2(user)}. It signs in at once with ${signIn}.`,
1541
1845
  ...userLines(user)
1542
1846
  ]);
1543
1847
  }
@@ -1562,7 +1866,7 @@ async function runBackendUsersDelete(context) {
1562
1866
  "USER_NOT_FOUND",
1563
1867
  `No user has the id, username or email ${printable(given)}. \`naro backend users\` lists them.`
1564
1868
  );
1565
- const named = user.id === label(user) ? label(user) : `${label(user)} (${printable(user.id)})`;
1869
+ const named = user.id === label2(user) ? label2(user) : `${label2(user)} (${printable(user.id)})`;
1566
1870
  await confirmOrAbort(
1567
1871
  context,
1568
1872
  `Delete the user ${named}? Their sessions end at once and the account cannot be brought back.`
@@ -4583,206 +4887,35 @@ async function runGenTypes(context) {
4583
4887
  ]);
4584
4888
  }
4585
4889
 
4586
- // src/commands/insights-format.ts
4587
- var OFF_LINE = "Speed Insights is off for this project. Turn it on: naro insights enable";
4588
- var TURNED_ON = "Speed Insights is on. Measuring starts within about a minute; values arrive when visitors leave a page.";
4589
- var TURNED_OFF = "Speed Insights is off. Measuring stops within about a minute; turning it back on shows past measurements again.";
4590
- var RANGE_TEXT = {
4591
- "24h": "last 24 hours",
4592
- "7d": "last 7 days",
4593
- "30d": "last 30 days"
4594
- };
4595
- var PART_TEXT = {
4596
- ttfb: "TTFB",
4597
- loadDelay: "load delay",
4598
- loadDuration: "load duration",
4599
- renderDelay: "render delay",
4600
- inputDelay: "input delay",
4601
- processing: "processing",
4602
- presentation: "presentation"
4603
- };
4604
- function rangeText(range) {
4605
- const known = RANGE_TEXT;
4606
- return known[range] ?? printable(range);
4607
- }
4608
- function partText(name) {
4609
- const known = PART_TEXT;
4610
- return known[name] ?? printable(name);
4611
- }
4612
- var ATTRIBUTED = /* @__PURE__ */ new Set([
4613
- "lcp",
4614
- "inp",
4615
- "cls"
4616
- ]);
4617
- var DEPLOYMENT_ROWS = 10;
4618
- var SUBJECT_LENGTH = 50;
4619
- function formatMs(ms) {
4620
- const rounded = Math.round(ms);
4621
- return rounded < 1e3 ? `${String(rounded)} ms` : `${(rounded / 1e3).toFixed(2)} s`;
4622
- }
4623
- function formatValue(metric, value) {
4624
- return metric === "cls" ? value.toFixed(2) : formatMs(value);
4625
- }
4626
- function formatChange(metric, change2) {
4627
- const size = formatValue(metric, Math.abs(change2));
4628
- if (Number.parseFloat(size) === 0) return "\xB10";
4629
- return change2 > 0 ? `\u25B2 +${size}` : `\u25BC \u2212${size}`;
4630
- }
4631
- function formatCount(count2) {
4632
- return Math.round(count2).toLocaleString("en-US");
4633
- }
4634
- function percent(part, total) {
4635
- return `${String(Math.round(part / total * 100))}%`;
4636
- }
4637
- function label2(metric) {
4638
- return metric.toUpperCase();
4639
- }
4640
- function environmentSuffix(environment) {
4641
- return environment === "preview" ? ", preview" : "";
4642
- }
4643
- function environmentWord(environment) {
4644
- return environment === "preview" ? "preview " : "";
4645
- }
4646
- function table(rows) {
4647
- const widths = [];
4648
- for (const row of rows)
4649
- row.forEach((cell, i) => {
4650
- widths[i] = Math.max(widths[i] ?? 0, cell.length);
4651
- });
4652
- return rows.map(
4653
- (row) => ` ${row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.trimEnd()
4654
- );
4655
- }
4656
- function summaryLines(report) {
4657
- return table([
4658
- ["Metric", "p75", "Good", "Needs improvement", "Poor", "Visits"],
4659
- ...SPEED_INSIGHTS_METRICS.map((metric) => {
4660
- const summary = report.summary[metric];
4661
- if (summary.p75 === null || summary.samples <= 0)
4662
- return [label2(metric), "\u2014", "\u2014", "\u2014", "\u2014", "0"];
4663
- return [
4664
- label2(metric),
4665
- formatValue(metric, summary.p75),
4666
- percent(summary.good, summary.samples),
4667
- percent(summary.needsImprovement, summary.samples),
4668
- percent(summary.poor, summary.samples),
4669
- formatCount(summary.samples)
4670
- ];
4671
- })
4672
- ]);
4673
- }
4674
- function breakdownTable(heading, metric, rows) {
4675
- if (rows.length === 0) return [" (none)"];
4676
- return table([
4677
- ["p75", "Visits", heading],
4678
- ...rows.map((row) => [
4679
- formatValue(metric, row.p75),
4680
- formatCount(row.samples),
4681
- printable(row.name)
4682
- ])
4683
- ]);
4684
- }
4685
- function elementLines(metric, elements) {
4686
- const lines = ["", `${label2(metric)} elements behind slow visits`];
4687
- if (elements.length === 0) return [...lines, " (none)"];
4688
- for (const row of elements) {
4689
- lines.push(
4690
- ` ${printable(row.element)} on ${printable(row.route)}: ${formatCount(row.slow)} slow visits, average ${formatValue(metric, row.average)}`
4691
- );
4692
- if (row.parts && row.parts.length > 0)
4693
- lines.push(
4694
- ` ${row.parts.map((part) => `${partText(part.name)} ${formatMs(part.ms)}`).join(" \xB7 ")}`
4695
- );
4696
- if (row.url) lines.push(` image ${printable(row.url)}`);
4697
- }
4698
- return lines;
4699
- }
4700
- function deploymentName(row) {
4701
- if (row.source === "cli" || !row.commitSha) return row.id.slice(0, 8);
4702
- const sha = printable(row.commitSha.slice(0, 7));
4703
- const subject = [
4704
- ...printable(row.commitMessage?.split("\n")[0]?.trim() ?? "")
4705
- ];
4706
- if (subject.length === 0) return sha;
4707
- return subject.length > SUBJECT_LENGTH ? `${sha} ${subject.slice(0, SUBJECT_LENGTH - 1).join("")}\u2026` : `${sha} ${subject.join("")}`;
4708
- }
4709
- function changeText(metric, row) {
4710
- if (row.samples < SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES)
4711
- return "too few visits";
4712
- if (row.change === null) return "\u2013";
4713
- return formatChange(metric, row.change);
4714
- }
4715
- function deploymentLines(metric, deployments, environment) {
4716
- if (deployments === null) return [];
4717
- const lines = [
4718
- "",
4719
- `${label2(metric)} by ${environmentWord(environment)}deployment, newest first`
4720
- ];
4721
- const measured = deployments.filter((row) => row.samples > 0).slice(0, DEPLOYMENT_ROWS);
4722
- if (measured.length === 0) return [...lines, " (none)"];
4723
- return [
4724
- ...lines,
4725
- ...table([
4726
- ["", "p75", "Visits", "Change", "Deployment"],
4727
- ...measured.map((row) => [
4728
- row.liveUntil === null ? "Live" : "",
4729
- row.p75 === null ? "\u2014" : formatValue(metric, row.p75),
4730
- formatCount(row.samples),
4731
- changeText(metric, row),
4732
- deploymentName(row)
4733
- ])
4734
- ])
4735
- ];
4736
- }
4737
- function reportLines(report, deployments) {
4738
- if (!report.enabled) return [OFF_LINE];
4739
- const { metric, range, environment } = report;
4740
- const head = `Speed Insights \u2014 ${label2(metric)}, ${rangeText(range)}${environmentSuffix(environment)}`;
4741
- if (SPEED_INSIGHTS_METRICS.every((m) => report.summary[m].samples <= 0))
4742
- return [
4743
- head,
4744
- "",
4745
- `No ${environmentWord(environment)}measurements in the ${rangeText(range)} yet. Values arrive when visitors leave a page.`
4746
- ];
4747
- return [
4748
- head,
4749
- "",
4750
- ...summaryLines(report),
4751
- "",
4752
- `${label2(metric)} by page`,
4753
- ...breakdownTable("Page", metric, report.paths),
4754
- "",
4755
- `${label2(metric)} by device`,
4756
- ...breakdownTable("Device", metric, report.devices),
4757
- ...ATTRIBUTED.has(metric) ? elementLines(metric, report.elements) : [],
4758
- ...deploymentLines(metric, deployments, environment)
4759
- ];
4760
- }
4761
- function routePathsLines(result) {
4762
- return [
4763
- `${label2(result.metric)} p75 under ${printable(result.route)}, ${rangeText(result.range)}${environmentSuffix(result.environment)}`,
4764
- ...breakdownTable("Path", result.metric, result.paths)
4765
- ];
4766
- }
4767
-
4768
4890
  // src/commands/insights.ts
4769
- function queryOf(flags) {
4891
+ function queryOf2(flags) {
4892
+ const filters = filtersOf(flags, SPEED_INSIGHTS_FILTER_KEYS);
4770
4893
  return {
4771
4894
  ...flags.range !== void 0 ? { range: flags.range } : {},
4772
4895
  ...flags.metric !== void 0 ? { metric: flags.metric } : {},
4773
- ...flags.env !== void 0 ? { environment: flags.env } : {}
4896
+ ...flags.env !== void 0 ? { environment: flags.env } : {},
4897
+ ...Object.keys(filters).length > 0 ? { filters } : {}
4774
4898
  };
4775
4899
  }
4776
4900
  async function runInsights(context) {
4901
+ if (context.flags.referrer !== void 0 && context.flags.referrer !== "")
4902
+ throw new NaroError(
4903
+ "VALIDATION_FAILED",
4904
+ "Speed Insights has no referrer; drop --referrer."
4905
+ );
4777
4906
  const client = await context.client();
4778
4907
  const projectId = await context.resolveProjectId();
4779
- const query = queryOf(context.flags);
4908
+ const query = queryOf2(context.flags);
4909
+ const filters = query.filters ?? {};
4780
4910
  if (context.flags.route !== void 0) {
4781
4911
  const result = await client.insights.paths(projectId, {
4782
4912
  ...query,
4783
4913
  route: context.flags.route
4784
4914
  });
4785
- context.output.result({ projectId, ...result }, routePathsLines(result));
4915
+ context.output.result(
4916
+ { projectId, ...result },
4917
+ routePathsLines(result, filters)
4918
+ );
4786
4919
  return;
4787
4920
  }
4788
4921
  const overview = await client.insights.overview(projectId, query);
@@ -4792,7 +4925,7 @@ async function runInsights(context) {
4792
4925
  );
4793
4926
  context.output.result(
4794
4927
  { projectId, ...overview },
4795
- reportLines(overview.report, overview.deployments)
4928
+ reportLines(overview.report, overview.deployments, filters)
4796
4929
  );
4797
4930
  }
4798
4931
  async function setInsights(context, enabled) {
@@ -4801,6 +4934,12 @@ async function setInsights(context, enabled) {
4801
4934
  "VALIDATION_FAILED",
4802
4935
  "Speed Insights has one switch for production and preview; drop --env."
4803
4936
  );
4937
+ const filter = givenFilterFlag(context.flags);
4938
+ if (filter !== void 0)
4939
+ throw new NaroError(
4940
+ "VALIDATION_FAILED",
4941
+ `Filters only narrow a report; drop --${filter}.`
4942
+ );
4804
4943
  const client = await context.client();
4805
4944
  const projectId = await context.resolveProjectId();
4806
4945
  const result = await client.insights.setEnabled(projectId, enabled);
@@ -5133,7 +5272,7 @@ function parseSize(text) {
5133
5272
  throw new NaroError("VALIDATION_FAILED", SIZE_MESSAGE);
5134
5273
  return bytes;
5135
5274
  }
5136
- function binarySize(bytes) {
5275
+ function binarySize(bytes, decimals = 1) {
5137
5276
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
5138
5277
  let value = bytes;
5139
5278
  let unit = 0;
@@ -5141,7 +5280,7 @@ function binarySize(bytes) {
5141
5280
  value /= 1024;
5142
5281
  unit += 1;
5143
5282
  }
5144
- const shown = unit === 0 ? String(value) : String(Number(value.toFixed(1)));
5283
+ const shown = unit === 0 ? String(value) : String(Number(value.toFixed(decimals)));
5145
5284
  return `${shown} ${units[unit] ?? "B"}`;
5146
5285
  }
5147
5286
  function count(n, noun) {
@@ -5169,6 +5308,51 @@ function bucketLine(bucket) {
5169
5308
  const line = ` ${cells.join(" ")}`;
5170
5309
  return bucket.deletingAt === null ? line : `${line} (being removed: \`naro storage bucket remove ${name} --empty\` finishes it)`;
5171
5310
  }
5311
+ var WARN_PERCENT = 80;
5312
+ function utcClock(iso) {
5313
+ const at = new Date(iso);
5314
+ return Number.isNaN(at.getTime()) ? null : at.toISOString().slice(11, 16);
5315
+ }
5316
+ function warnedPercent(value, limit) {
5317
+ if (limit === null || !(limit > 0)) return null;
5318
+ const percent2 = Math.floor(value * 100 / limit);
5319
+ return percent2 >= WARN_PERCENT ? percent2 : null;
5320
+ }
5321
+ function meterLines(info) {
5322
+ const { today, limits } = info;
5323
+ if (today === null) return [];
5324
+ const reads = count(today.classBOps, "read");
5325
+ const writes = today.classAOps.toLocaleString("en-US");
5326
+ const dailyOps = limits.dailyOps?.toLocaleString("en-US") ?? null;
5327
+ const lines = [
5328
+ dailyOps === null ? `Today (UTC): ${writes} write operations, ${reads}` : `Today (UTC): ${writes} of ${dailyOps} write operations, ${reads}`
5329
+ ];
5330
+ if (today.measuredBytes === null)
5331
+ lines.push("Measured: not yet (the meter reads R2 every 15 minutes)");
5332
+ else {
5333
+ const time = today.measuredAt === null ? null : utcClock(today.measuredAt);
5334
+ lines.push(
5335
+ `Measured: ${binarySize(today.measuredBytes, 2)} in R2${time === null ? "" : ` at ${time} UTC`}`
5336
+ );
5337
+ }
5338
+ if (today.bytesBlocked)
5339
+ lines.push(
5340
+ today.measuredBytes === null ? `\u2717 Uploads are stopped: the bucket is at the storage limit of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour).` : `\u2717 Uploads are stopped: R2 holds ${binarySize(today.measuredBytes, 2)} of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour).`
5341
+ );
5342
+ if (today.opsBlocked)
5343
+ lines.push(
5344
+ dailyOps === null ? "\u2717 Uploads are stopped until 00:00 UTC: today's write operations are used. Downloads still work." : `\u2717 Uploads are stopped until 00:00 UTC: today's ${dailyOps} write operations are used. Downloads still work.`
5345
+ );
5346
+ const storage = today.bytesBlocked || today.measuredBytes === null ? null : warnedPercent(today.measuredBytes, limits.bytes);
5347
+ if (storage !== null)
5348
+ lines.push(`\u26A0 ${String(storage)}% of the storage limit is used.`);
5349
+ const operations = today.opsBlocked ? null : warnedPercent(today.classAOps, limits.dailyOps);
5350
+ if (operations !== null)
5351
+ lines.push(
5352
+ `\u26A0 ${String(operations)}% of today's write operations are used.`
5353
+ );
5354
+ return lines;
5355
+ }
5172
5356
  function storageLines(info) {
5173
5357
  if (!info.enabled)
5174
5358
  return [
@@ -5180,6 +5364,7 @@ function storageLines(info) {
5180
5364
  `Storage: on (R2 bucket ${printable(info.bucket ?? "unknown")})`,
5181
5365
  `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per upload`,
5182
5366
  usage === null ? "Used: not known yet: the backend is not ready (`naro backend` shows where it got to)" : `Used: ${binarySize(usage.bytes)} in ${count(usage.objects, "object")}`,
5367
+ ...meterLines(info),
5183
5368
  info.buckets.length === 0 ? "Buckets: none yet (`naro storage bucket create <name>` makes one)" : `Buckets: ${String(info.buckets.length)}`,
5184
5369
  ...info.buckets.map(bucketLine)
5185
5370
  ];
@@ -8061,9 +8246,13 @@ Usage:
8061
8246
  naro logs [deployment-id] Runtime logs (or build logs of a deployment)
8062
8247
  [--type build|runtime] [--env production|preview] [--level error] [--follow]
8063
8248
 
8064
- naro insights [--metric lcp] [--range 7d] [--env preview]
8249
+ naro analytics [--range 7d] [--path p] [--referrer host] [--country KR] [--device mobile] [--browser safari] [--os ios]
8250
+ Web Analytics: visitors and page views, then the top pages,
8251
+ referrers, countries, devices, browsers and operating systems
8252
+ naro insights [--metric lcp] [--range 7d] [--env preview] [--path p] [--country KR] [--device mobile] [--browser safari] [--os ios]
8065
8253
  Speed Insights: Web Vitals p75 by page, device, the elements
8066
- behind slow visits and deployment (production by default)
8254
+ behind slow visits and deployment, and the page load: its p75
8255
+ and the mean of each step (production by default)
8067
8256
  naro insights --route '/posts/[id]' The paths opened under one route
8068
8257
  naro insights enable | disable Turn measuring on or off (production pages get a
8069
8258
  same-origin script, preview pages too; past measurements are kept)
@@ -8185,7 +8374,7 @@ Usage:
8185
8374
  naro_insert, naro_update, naro_delete (shorthand for CREATE POLICY)
8186
8375
  naro policy remove <table> Drop every policy on it: back to deny (asks first)
8187
8376
 
8188
- naro storage [status] Naro Storage: on or off, limits, what is stored, the buckets
8377
+ naro storage [status] Naro Storage: on or off, limits, what is stored, today's operations, the buckets
8189
8378
  naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
8190
8379
  (safe to re-run; applies a plan change's limits)
8191
8380
  naro storage bucket list The buckets: public or private, objects, bytes, limits
@@ -8221,8 +8410,14 @@ Global options:
8221
8410
  --prod Deploy/target production
8222
8411
  --wait Poll until the domain goes live (naro domains verify)
8223
8412
  --metric <m> lcp, inp, cls, fcp or ttfb; lcp by default (naro insights)
8224
- --range <r> 24h, 7d or 30d; 7d by default (naro insights)
8413
+ --range <r> 24h, 7d or 30d; 7d by default (naro analytics, naro insights)
8225
8414
  --route <pattern> A route from the pages table, e.g. '/posts/[id]': the paths under it (naro insights)
8415
+ --path <p> Only this page, starting with /, e.g. /pricing (naro analytics, naro insights)
8416
+ --referrer <host> Only visits that came from this lowercase host, e.g. t.co (naro analytics only: Speed Insights has no referrer)
8417
+ --country <cc> Only this country, an uppercase two-letter code like KR (naro analytics, naro insights)
8418
+ --device <d> Only this device: desktop, mobile or tablet (naro analytics, naro insights)
8419
+ --browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
8420
+ --os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
8226
8421
  --env <e> production or preview (naro logs, naro insights: production by default; naro env pull: preview by default)
8227
8422
  --no-www Register the apex alone (naro domains add)
8228
8423
  --file <path> SQL file to run (naro db query, naro policy create)
@@ -8438,6 +8633,9 @@ function route(parsed) {
8438
8633
  return { handler: runCancel, positionals };
8439
8634
  case "logs":
8440
8635
  return { handler: runLogs, positionals };
8636
+ // Spec 2026-10-08-analytics-cli-pageload §5.
8637
+ case "analytics":
8638
+ return positionals.length === 0 ? { handler: runAnalytics, positionals: [] } : null;
8441
8639
  // Spec 2026-10-08 §5.
8442
8640
  case "insights": {
8443
8641
  if (positionals.length > 1) return null;