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/README.md +53 -2
- package/dist/{chunk-O4NJRDJE.js → chunk-6WJ3QQ5Y.js} +98 -26
- package/dist/deploy.js +1 -1
- package/dist/index.js +531 -333
- package/package.json +5 -5
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-
|
|
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/
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
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 ${
|
|
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 ===
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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;
|