@foldspace_npm/harness 0.1.26 → 0.1.28
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/CLAUDE.md +402 -204
- package/README.md +84 -36
- package/bin/ask.mjs +19 -9
- package/bin/attach.mjs +91 -56
- package/bin/badge.mjs +28 -8
- package/bin/cli.mjs +23 -6
- package/bin/inject.mjs +3 -4
- package/bin/observe.mjs +22 -6
- package/bin/published.mjs +141 -0
- package/package.json +6 -2
- package/recipes/INDEX.md +13 -8
- package/recipes/README.md +30 -7
- package/recipes/account-overview/README.md +6 -6
- package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
- package/recipes/account-overview/agent/views/brand.ts +23 -14
- package/recipes/account-overview/agent/views/overview.ts +13 -17
- package/recipes/bottom-bar/README.md +12 -9
- package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
- package/recipes/create-a-record/README.md +97 -0
- package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
- package/recipes/create-a-record/agent/api/projects.ts +62 -0
- package/recipes/create-a-record/agent/projects.ts +145 -0
- package/recipes/create-a-record/agent/views/approval.ts +181 -0
- package/recipes/create-a-record/agent/views/brand.ts +35 -0
- package/recipes/create-a-record/agent/views/fields.ts +252 -0
- package/recipes/create-a-record/agent/views/project-card.ts +253 -0
- package/recipes/create-a-record/agent/views/steps.ts +92 -0
- package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
- package/recipes/create-a-record/fixtures/project.created.json +1 -0
- package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
- package/recipes/create-a-record/recipe.json +13 -0
- package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
- package/recipes/find-by-name/README.md +3 -1
- package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
- package/recipes/lookup-then-view/README.md +73 -0
- package/recipes/lookup-then-view/agent/accounts.ts +174 -0
- package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
- package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
- package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
- package/recipes/lookup-then-view/agent/views/account.ts +183 -0
- package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
- package/recipes/lookup-then-view/agent/views/format.ts +73 -0
- package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
- package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
- package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
- package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
- package/recipes/lookup-then-view/recipe.json +11 -0
- package/recipes/lookup-then-view/studio/find_account.json +10 -0
- package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
- package/recipes/opportunities-at-risk/README.md +5 -5
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
- package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
- package/recipes/pick-from-a-list/README.md +7 -6
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
- package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
- package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
- package/recipes/prepare-for-a-meeting/README.md +4 -4
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
- package/recipes/records-list/README.md +6 -6
- package/recipes/records-list/agent/accounts.ts +3 -1
- package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
- package/recipes/records-list/agent/views/brand.ts +26 -14
- package/recipes/server-rendered-forms/README.md +35 -0
- package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
- package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
- package/recipes/server-rendered-forms/agent/forms.ts +192 -0
- package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
- package/recipes/server-rendered-forms/fixtures/form.html +17 -0
- package/recipes/server-rendered-forms/fixtures/list.html +9 -0
- package/recipes/server-rendered-forms/fixtures/record.html +4 -0
- package/recipes/server-rendered-forms/recipe.json +10 -0
- package/recipes/several-hosts/README.md +10 -13
- package/recipes/several-hosts/agent/hosts.ts +19 -136
- package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
- package/recipes/several-hosts/recipe.json +3 -3
- package/recipes/swap-the-login-method/README.md +16 -5
- package/recipes/swap-the-login-method/agent/utils.ts +52 -3
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
- package/recipes/update-meeting-notes/README.md +10 -4
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
- package/recipes/upload-contacts/README.md +5 -5
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
- package/recipes/upload-contacts/agent/views/brand.ts +24 -14
- package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
- package/recipes/who-is-the-user/README.md +7 -3
- package/src/ask-core.mjs +16 -4
- package/src/attach-daemon.mjs +9 -2
- package/src/attach-helpers.mjs +105 -0
- package/src/attach-preflight.mjs +34 -11
- package/src/badge-core.mjs +1 -1
- package/src/cdp-client.mjs +15 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +102 -47
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/keep-focus.mjs +17 -13
- package/src/lint/index.mjs +8 -1
- package/src/lint/rules.mjs +240 -43
- package/src/observe-core.mjs +163 -34
- package/src/published-core.mjs +392 -0
- package/src/region.mjs +1 -1
- package/src/runtime/agent.ts +16 -12
- package/src/runtime/http.ts +25 -15
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +4 -4
- package/src/transports/README.md +2 -2
- package/src/upgrade.mjs +33 -10
- package/templates/agent-starter/CLAUDE.md +8 -5
- package/templates/agent-starter/README.md +32 -33
- package/templates/agent-starter/agent/actions/_example.ts +4 -4
- package/templates/agent-starter/agent/actions/index.ts +2 -2
- package/templates/agent-starter/agent/constants.ts +4 -3
- package/templates/agent-starter/agent/utils.ts +15 -18
- package/templates/agent-starter/claude-settings.json +2 -1
- package/templates/agent-starter/docs/app-profile.md +19 -14
- package/templates/agent-starter/foldspace.dev.json +1 -1
- package/recipes/several-hosts/fixtures/nav-record.json +0 -5
package/src/observe-core.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export function isSameSite(url, pageUrl, hosts = []) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* One product often lives on several hosts (www.,
|
|
47
|
+
* One product often lives on several hosts (www., reports., admin. ...), and a
|
|
48
48
|
* project only works on the hosts it names. Given addresses seen from a page,
|
|
49
49
|
* say which hosts look like the same product but are NOT covered, and the one
|
|
50
50
|
* pattern that would cover them. The site is an approximation (see siteOf), so
|
|
@@ -81,7 +81,7 @@ export function uncoveredSiblings(urls, pageUrl, hosts = []) {
|
|
|
81
81
|
hint:
|
|
82
82
|
`This product also lives on ${found.slice(0, 4).map((entry) => entry.host).join(", ")}` +
|
|
83
83
|
`${found.length > 4 ? ` and ${found.length - 4} more` : ""}, which this project does not cover. ` +
|
|
84
|
-
`To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach
|
|
84
|
+
`To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with.`,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -132,7 +132,7 @@ export function gotoDestination(input, pageUrl, hosts = []) {
|
|
|
132
132
|
throw new Error(
|
|
133
133
|
`--goto: ${target.hostname} is not one of this project's hosts (${hosts.join(", ") || "none named"}). ` +
|
|
134
134
|
(coverage.pattern
|
|
135
|
-
? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach
|
|
135
|
+
? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with. If it is not, --goto a screen on this project's hosts.`
|
|
136
136
|
: "observe only opens screens of the product this project is for."),
|
|
137
137
|
);
|
|
138
138
|
}
|
|
@@ -398,6 +398,11 @@ export function rankCandidates(candidates, words = [], max = 8) {
|
|
|
398
398
|
|
|
399
399
|
const WRITE_FLAGS = ["method", "data", "body", "header", "headers", "json", "post", "put", "delete"];
|
|
400
400
|
|
|
401
|
+
/** True for an option that would make `observe read` write (`--method`, `--body`, ...). */
|
|
402
|
+
export function isWriteFlag(option) {
|
|
403
|
+
return WRITE_FLAGS.includes(String(option || "").replace(/^--/, ""));
|
|
404
|
+
}
|
|
405
|
+
|
|
401
406
|
/**
|
|
402
407
|
* `observe read` is GET only. There is deliberately no way to pick a method,
|
|
403
408
|
* add a header or send a body: anything else belongs in an action handler the
|
|
@@ -407,7 +412,7 @@ export function refuseWriteFlags(flags = {}) {
|
|
|
407
412
|
const asked = WRITE_FLAGS.filter((name) => name in flags);
|
|
408
413
|
if (asked.length) {
|
|
409
414
|
throw new Error(
|
|
410
|
-
`observe read is GET only
|
|
415
|
+
`observe read is GET only - it never sets a method, a header or a body (refused: ${asked
|
|
411
416
|
.map((name) => `--${name}`)
|
|
412
417
|
.join(", ")}).`,
|
|
413
418
|
);
|
|
@@ -706,8 +711,9 @@ export function looksLoggedIn({ url, hasPasswordField = false, hosts = [] }) {
|
|
|
706
711
|
|
|
707
712
|
/** Computed colours come back as rgb()/rgba(); anything else passes through. */
|
|
708
713
|
// oklch / oklab, as modern apps write their palettes and as Chrome reports
|
|
709
|
-
// them back, converted to sRGB
|
|
710
|
-
//
|
|
714
|
+
// them back, converted to sRGB hex like rgb() and rgba(): brandFile checks
|
|
715
|
+
// contrast and works out its other keys from hex. lab(), lch() and color()
|
|
716
|
+
// pass through; brandFile writes a default in their place.
|
|
711
717
|
function okToHex(text) {
|
|
712
718
|
const match = /^(oklch|oklab)\(\s*([\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(text);
|
|
713
719
|
if (!match) return null;
|
|
@@ -770,57 +776,180 @@ function luminance(hex) {
|
|
|
770
776
|
|
|
771
777
|
const isHex = (value) => /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(String(value || ""));
|
|
772
778
|
|
|
779
|
+
/** `share` of the way from one #RRGGBB to another, channel by channel. */
|
|
780
|
+
function mixHex(from, to, share) {
|
|
781
|
+
const channels = (hex) => [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16));
|
|
782
|
+
const target = channels(to);
|
|
783
|
+
return `#${channels(from)
|
|
784
|
+
.map((value, i) => Math.round(value + (target[i] - value) * share).toString(16).padStart(2, "0"))
|
|
785
|
+
.join("")
|
|
786
|
+
.toUpperCase()}`;
|
|
787
|
+
}
|
|
788
|
+
|
|
773
789
|
/**
|
|
774
790
|
* `agent/views/brand.ts` from what `observe styles` sampled: the app's own
|
|
775
|
-
* accent, text, border, font and
|
|
776
|
-
*
|
|
791
|
+
* accent, text, muted text, border, font, radius and text size, on white (the
|
|
792
|
+
* panel's default background), plus every other key a recipe card reads - a
|
|
793
|
+
* fixed colour (surface2 and the status colours), one worked out from the
|
|
794
|
+
* sampled ones, or another name for one of them. A see-through text, muted or
|
|
795
|
+
* border colour is flattened onto the white; a see-through accent keeps its
|
|
796
|
+
* colour at full strength, so a tinted button still gives the app's own accent.
|
|
797
|
+
* A text, muted, border or accent colour still too light to read on white falls
|
|
798
|
+
* back to its default and says so. A dark app keeps its font, radius, text size
|
|
799
|
+
* and (unless it is too light) its accent; its light-on-dark text and borders
|
|
777
800
|
* would vanish on white, so those fall back to neutral greys and say so.
|
|
778
801
|
*/
|
|
779
|
-
export function brandFile(styles, { url, date
|
|
802
|
+
export function brandFile(styles, { url, date } = {}) {
|
|
780
803
|
const notes = [];
|
|
781
804
|
const pick = (value, fallback, name) => {
|
|
782
805
|
const hex = value ? toHex(value) : null; // rgb / oklch / oklab -> hex; anything else stays as it was
|
|
783
806
|
if (isHex(hex)) return hex.slice(0, 7).toUpperCase();
|
|
784
|
-
if (value) notes.push(`${name}: sampled "${value}"
|
|
807
|
+
if (value) notes.push(`${name}: sampled "${value}" could not be converted to hex - using ${fallback}`);
|
|
785
808
|
return fallback;
|
|
786
809
|
};
|
|
810
|
+
// A see-through colour (rgba, or hex with alpha) is drawn on the panel's
|
|
811
|
+
// white, so flatten it onto white: dropping the alpha would turn an
|
|
812
|
+
// rgba(0, 0, 0, 0.12) border, and every key worked out from it, black.
|
|
813
|
+
const onPanel = (value, fallback, name) => {
|
|
814
|
+
const hex = value ? toHex(value) : null;
|
|
815
|
+
const alpha = isHex(hex) && hex.length === 9 ? parseInt(hex.slice(7), 16) / 255 : 1;
|
|
816
|
+
return mixHex("#FFFFFF", pick(value, fallback, name), alpha);
|
|
817
|
+
};
|
|
787
818
|
const background = pick(styles.background, "#FFFFFF", "background");
|
|
788
819
|
const darkApp = (luminance(background) ?? 1) < 0.4;
|
|
789
|
-
if (darkApp) notes.push(`the app is dark (${background}); cards sit in the white
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const
|
|
820
|
+
if (darkApp) notes.push(`the app is dark (${background}); cards sit in the panel, white by default, so text, muted and border are neutral greys`);
|
|
821
|
+
// A colour too light to read on white - white nav text on a light app, a
|
|
822
|
+
// white button on a dark one - falls back to its default, and says so.
|
|
823
|
+
const readable = (resolve, value, fallback, name, limit) => {
|
|
824
|
+
const hex = resolve(value, fallback, name);
|
|
825
|
+
if ((luminance(hex) ?? 0) <= limit) return hex;
|
|
826
|
+
notes.push(`${name}: sampled "${value}" would not read on white - using ${fallback}`);
|
|
827
|
+
return fallback;
|
|
828
|
+
};
|
|
829
|
+
const text = darkApp ? "#111827" : readable(onPanel, styles.text, "#111827", "text", 0.6);
|
|
830
|
+
const muted = darkApp ? "#6B7280" : readable(onPanel, styles.textMuted, "#6B7280", "textMuted", 0.75);
|
|
831
|
+
const border = darkApp ? "#E5E7EB" : readable(onPanel, styles.border, "#E5E7EB", "border", 0.97);
|
|
793
832
|
const buttonBackground = styles.primaryButton?.background;
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const
|
|
833
|
+
const accentSample = buttonBackground || styles.link;
|
|
834
|
+
// The accent keeps its colour at full strength: a tinted button such as
|
|
835
|
+
// rgba(50, 72, 242, 0.08) still gives the app's own blue, which the cards
|
|
836
|
+
// draw white text on.
|
|
837
|
+
const accent = readable(pick, accentSample, "#3248F2", "accent", 0.85);
|
|
838
|
+
// The accent is the app's only when the sample could be converted to hex
|
|
839
|
+
// and reads on white; otherwise a NOTE says why it is the default.
|
|
840
|
+
const accentFromApp = Boolean(accentSample) && !notes.some((note) => note.startsWith("accent:"));
|
|
841
|
+
const accentSource = !accentSample
|
|
842
|
+
? "a default blue (nothing sampled)"
|
|
843
|
+
: !accentFromApp
|
|
844
|
+
? "a default blue (see the NOTE on accent)"
|
|
845
|
+
: buttonBackground
|
|
846
|
+
? "the app's primary button"
|
|
847
|
+
: "the app's links";
|
|
848
|
+
// Only a solid button's text is kept, and only when the accent is that
|
|
849
|
+
// button's: a see-through button's text was chosen for its tint.
|
|
850
|
+
const buttonHex = toHex(buttonBackground);
|
|
851
|
+
const buttonText = accentFromApp && isHex(buttonHex) && buttonHex.length === 7 ? styles.primaryButton?.color : null;
|
|
852
|
+
const accentText = pick(buttonText, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
|
|
799
853
|
const font = String(styles.fontFamily || "").replace(/["'\\]/g, "").trim() || "Roboto, sans-serif";
|
|
800
854
|
const radius = /^\d+(\.\d+)?px$/.test(String(styles.boxRadius || "")) ? styles.boxRadius : "8px";
|
|
855
|
+
// The app's most used text size, kept to 12-16px so a card fits the panel.
|
|
856
|
+
const sampledSize = /^(\d+(?:\.\d+)?)px$/.exec(String(styles.fontSize || ""));
|
|
857
|
+
const base = sampledSize ? Math.min(16, Math.max(12, Math.round(Number(sampledSize[1])))) : 14;
|
|
858
|
+
const white = "#FFFFFF";
|
|
859
|
+
const black = "#000000";
|
|
860
|
+
const good = "#047857";
|
|
861
|
+
const warn = "#B45309";
|
|
862
|
+
const derived = {
|
|
863
|
+
textStrong: mixHex(text, black, 0.5),
|
|
864
|
+
faint: mixHex(muted, white, 0.35),
|
|
865
|
+
row: mixHex(border, white, 0.75),
|
|
866
|
+
tableHead: mixHex(border, white, 0.5),
|
|
867
|
+
accentDeep: mixHex(accent, black, 0.15),
|
|
868
|
+
accentSoft: mixHex(accent, white, 0.9),
|
|
869
|
+
headerStart: mixHex(accent, white, 0.92),
|
|
870
|
+
};
|
|
871
|
+
// One shape for every card: each recipe's brand.ts is a placeholder with
|
|
872
|
+
// these keys, in this order.
|
|
873
|
+
const values = {
|
|
874
|
+
surface: white,
|
|
875
|
+
surface2: "#F5F6FA",
|
|
876
|
+
text,
|
|
877
|
+
muted,
|
|
878
|
+
border,
|
|
879
|
+
accent,
|
|
880
|
+
accentText,
|
|
881
|
+
font,
|
|
882
|
+
radius,
|
|
883
|
+
size: { base: `${base}px`, small: `${base - 2}px` },
|
|
884
|
+
...derived,
|
|
885
|
+
good,
|
|
886
|
+
warn,
|
|
887
|
+
warnInk: "#FEF3C7",
|
|
888
|
+
bad: "#B91C1C",
|
|
889
|
+
primary: accent,
|
|
890
|
+
tint: derived.tableHead,
|
|
891
|
+
disabled: derived.faint,
|
|
892
|
+
money: good,
|
|
893
|
+
ok: good,
|
|
894
|
+
deals: accent,
|
|
895
|
+
contacts: derived.accentDeep,
|
|
896
|
+
cases: warn,
|
|
897
|
+
};
|
|
898
|
+
const groups = {
|
|
899
|
+
textStrong: "Worked out from the values above.",
|
|
900
|
+
good: "Status colours: fixed, so green, amber and red mean the same in every app.",
|
|
901
|
+
primary: "Other names some cards use for the colours above; a new card uses the original key.",
|
|
902
|
+
};
|
|
903
|
+
const comments = {
|
|
904
|
+
surface2: "a second background: a fixed light grey",
|
|
905
|
+
accentText: isHex(toHex(buttonText))
|
|
906
|
+
? "text on accent: the app's button text"
|
|
907
|
+
: "text on accent: white, or near-black when accent is light",
|
|
908
|
+
size: sampledSize
|
|
909
|
+
? "base: the app's most used text size, kept to 12-16px; small: 2px less"
|
|
910
|
+
: "base: 14px (no text size was sampled); small: 2px less",
|
|
911
|
+
textStrong: "names and headings: text, halfway to black",
|
|
912
|
+
faint: "hints and empty states: muted, 35% toward white",
|
|
913
|
+
row: "row backgrounds and alternate rows: border, 75% toward white",
|
|
914
|
+
tableHead: "table heads and summary boxes: border, halfway to white",
|
|
915
|
+
accentDeep: "gradients and hover: accent, 15% toward black",
|
|
916
|
+
accentSoft: "chips, selected rows and focus rings: accent, 90% toward white",
|
|
917
|
+
headerStart: "card headers and stage tags: accent, 92% toward white",
|
|
918
|
+
good: "done, valid, amounts",
|
|
919
|
+
warn: "late, at risk, needs a look",
|
|
920
|
+
warnInk: "the light amber behind a warning tag",
|
|
921
|
+
bad: "failed, not valid, critical",
|
|
922
|
+
primary: "same as accent",
|
|
923
|
+
tint: "pick-from-a-list's head and selected row: same as tableHead",
|
|
924
|
+
disabled: "a button that cannot be pressed: same as faint",
|
|
925
|
+
money: "amounts: same as good",
|
|
926
|
+
ok: "same as good",
|
|
927
|
+
deals: "the open-deals count: same as accent",
|
|
928
|
+
contacts: "the contacts count: same as accentDeep",
|
|
929
|
+
cases: "the open-cases count: same as warn",
|
|
930
|
+
};
|
|
931
|
+
const literal = (key, value) =>
|
|
932
|
+
key === "size" ? `{ base: "${value.base}", small: "${value.small}" }` : key === "font" ? `'${value}'` : `"${value}"`;
|
|
801
933
|
const stamp = `${url || "the app"}${date ? ` on ${date}` : ""}`;
|
|
802
934
|
const lines = [
|
|
803
935
|
`// Brand reference for this product's cards. Sampled from ${stamp} with`,
|
|
804
|
-
"// `foldspace observe styles --write`. Cards live in the agent's panel,
|
|
805
|
-
"// white, so
|
|
806
|
-
"// are the app's own
|
|
807
|
-
"//
|
|
808
|
-
"//
|
|
936
|
+
"// `npx foldspace observe styles --write`. Cards live in the agent's panel,",
|
|
937
|
+
"// which is white by default, so surface is white. text, muted, border, accent,",
|
|
938
|
+
"// font, radius and size are the app's own, or a default where they were not",
|
|
939
|
+
"// sampled or would not read on white; every other key's comment, or the",
|
|
940
|
+
"// heading above it, says where it comes from. Keep every key, so any recipe",
|
|
941
|
+
"// card can use this file.",
|
|
942
|
+
"// Re-run the command to re-sample; do not edit by eye.",
|
|
809
943
|
`// accent: ${accentSource}.`,
|
|
810
944
|
...notes.map((note) => `// NOTE ${note}.`),
|
|
811
945
|
"",
|
|
812
946
|
"export const brand = {",
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
` border: "${border}",`,
|
|
818
|
-
` accent: "${accent}",`,
|
|
819
|
-
` accentText: "${accentText}",`,
|
|
820
|
-
` font: '${font}',`,
|
|
821
|
-
` radius: "${radius}",`,
|
|
947
|
+
...Object.entries(values).flatMap(([key, value]) => [
|
|
948
|
+
...(groups[key] ? [` // ${groups[key]}`] : []),
|
|
949
|
+
` ${key}: ${literal(key, value)},${comments[key] ? ` // ${comments[key]}` : ""}`,
|
|
950
|
+
]),
|
|
822
951
|
"} as const;",
|
|
823
952
|
"",
|
|
824
953
|
];
|
|
825
|
-
return { content: lines.join("\n"), notes, values
|
|
954
|
+
return { content: lines.join("\n"), notes, values };
|
|
826
955
|
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Pure logic behind `foldspace published <dev|prod>`, unit-tested.
|
|
2
|
+
//
|
|
3
|
+
// The command points the test window's agent at the code published to dev or
|
|
4
|
+
// prod, so `foldspace ask` checks that code instead of the local build. Each
|
|
5
|
+
// refusal here keeps that check honest: while attach runs the page serves the
|
|
6
|
+
// local build, and a page whose agent is not this project's would load another
|
|
7
|
+
// agent's code.
|
|
8
|
+
|
|
9
|
+
import { ACTION_OBSERVER_GLOBAL } from "./action-observer.mjs";
|
|
10
|
+
import { ATTACH_MODES, characterizePageAgents, parseAgentId } from "./attach-helpers.mjs";
|
|
11
|
+
import { detectedProductIds, guardAttachMode } from "./attach-preflight.mjs";
|
|
12
|
+
import { serializeDiagnostic } from "./diagnostics.mjs";
|
|
13
|
+
|
|
14
|
+
export const PUBLISHED_USAGE = "usage: foldspace published <dev|prod>";
|
|
15
|
+
|
|
16
|
+
// The public SDK's values for remoteActionsSettings.environment: "DEV" loads
|
|
17
|
+
// /agent/actions/dev/<product>/<agent>/index.js, anything else - "PROD" is
|
|
18
|
+
// the default - /agent/actions/prod/... (checked against script.eucerahive.io
|
|
19
|
+
// /web/sdk/foldspace.js, built 2026-10-04).
|
|
20
|
+
export const ENVIRONMENTS = Object.freeze({ dev: "DEV", prod: "PROD" });
|
|
21
|
+
|
|
22
|
+
export const ATTACH_RUNNING =
|
|
23
|
+
"Stop attach first: `npx foldspace attach --stop` - while it runs the page serves your local build.";
|
|
24
|
+
|
|
25
|
+
// No daemon runs, yet the page carries the local build again after a reload:
|
|
26
|
+
// an attach started without --daemon is connected, and `attach --stop` (which
|
|
27
|
+
// stops only the daemon) does not reach it.
|
|
28
|
+
export const FOREGROUND_ATTACH =
|
|
29
|
+
"This page still runs a local build after a reload, but no attach daemon runs for this project: an attach started without --daemon is still connected to the test window.";
|
|
30
|
+
|
|
31
|
+
export function foregroundAttachNext(env) {
|
|
32
|
+
return `Stop the attach that was started without --daemon - \`attach --stop\` stops only the daemon - then run \`npx foldspace published ${env}\` again.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const RELOAD_NOTE = "A reload puts the page back on what the agent loads normally.";
|
|
36
|
+
|
|
37
|
+
// Every published bundle assigns its handlers here; the SDK reads and deletes it.
|
|
38
|
+
export const REMOTE_ACTIONS_GLOBAL = "__FOLDSPACE_REMOTE_ACTIONS__";
|
|
39
|
+
|
|
40
|
+
// Every refusal below comes after `attach --stop`, so each next step starts by
|
|
41
|
+
// putting the local build back. A platform gap never stops the session.
|
|
42
|
+
const word = (env) => (env === "prod" ? "production" : "dev");
|
|
43
|
+
const lower = (text) => `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
44
|
+
|
|
45
|
+
/** The page is this project's agent, but this window cannot show the published code. */
|
|
46
|
+
export function notHere(env) {
|
|
47
|
+
return `Run \`npx foldspace attach --daemon\` to put your local build back in the test window, and tell the human in one sentence that the test window cannot show ${word(env)} yet - do not invite them to try ${word(env)} there.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Worth one more run; then the window cannot show it. */
|
|
51
|
+
function again(env) {
|
|
52
|
+
return `Run \`npx foldspace published ${env}\` once more. If it fails again: ${lower(notHere(env))}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The page needs --bootstrap or --replace to attach, which is not a site that
|
|
57
|
+
* loads this agent by itself: the manual sends the rest to the Foldspace team.
|
|
58
|
+
*/
|
|
59
|
+
function attachAgain(flag) {
|
|
60
|
+
return `Run \`npx foldspace attach --daemon ${flag}\` to put your local build back in the test window. A page that needs \`${flag}\` cannot show the published code, and production waits until their site loads this agent by itself: for the rest, give the human the Foldspace team answer (CLAUDE.md, "Done, and what you say at the end").`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// How long the agent gets to load the published code. The SDK gives up on the
|
|
64
|
+
// script itself after 10s.
|
|
65
|
+
export const LOAD_TIMEOUT_MS = 20_000;
|
|
66
|
+
|
|
67
|
+
/** "dev" | "prod" (any case) -> { name, sdkValue }, or throws the usage line. */
|
|
68
|
+
export function publishedEnvironment(value) {
|
|
69
|
+
const name = String(value ?? "").trim().toLowerCase();
|
|
70
|
+
if (!Object.hasOwn(ENVIRONMENTS, name)) throw new Error(PUBLISHED_USAGE);
|
|
71
|
+
return { name, sdkValue: ENVIRONMENTS[name] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The agent foldspace.dev.json names, as inject recorded it. */
|
|
75
|
+
export function projectAgent(launchTarget = {}, configTarget = {}) {
|
|
76
|
+
const target = { ...configTarget, ...launchTarget };
|
|
77
|
+
if (!target.productId || !target.agentApiName) {
|
|
78
|
+
throw new Error("foldspace.dev.json must define productId and agentApiName.");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
productId: String(target.productId),
|
|
82
|
+
apiName: String(target.agentApiName),
|
|
83
|
+
mode: String(target.mode || "OVERLAY").toUpperCase(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Page body: what attach's own preflight reads (inspectSdkState), whether
|
|
89
|
+
* attach prepared this document, and how long ago the document loaded.
|
|
90
|
+
*/
|
|
91
|
+
export function buildPageReadScript(agent) {
|
|
92
|
+
return `const state = await ${serializeDiagnostic("inspectSdkState", {
|
|
93
|
+
expectedApiName: agent.apiName,
|
|
94
|
+
})};
|
|
95
|
+
return {
|
|
96
|
+
state,
|
|
97
|
+
attachPrepared: Boolean(window[${JSON.stringify(ACTION_OBSERVER_GLOBAL)}] || window.__FOLDSPACE_DEV_REPLACE__),
|
|
98
|
+
ageMs: Math.round(performance.now()),
|
|
99
|
+
};`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Enough of the page has loaded to judge it: its agent is there, and no load
|
|
104
|
+
* of remote actions is in flight (the SDK removes that script once it lands).
|
|
105
|
+
*/
|
|
106
|
+
export function pageSettled(read) {
|
|
107
|
+
const state = read?.state;
|
|
108
|
+
if (!state) return false;
|
|
109
|
+
if (read.attachPrepared) return true;
|
|
110
|
+
const ready = state.documentReadyState === "interactive" || state.documentReadyState === "complete";
|
|
111
|
+
const loading = (state.scripts || []).some((source) => String(source).includes("/agent/actions/"));
|
|
112
|
+
return ready && state.sdkReady === true && (state.agentIds || []).length > 0 && !loading;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The mode the page created this agent in, when it has exactly one instance of it. */
|
|
116
|
+
export function modeOnPage(agentIds = [], apiName) {
|
|
117
|
+
const mine = agentIds.map(parseAgentId).filter((agent) => agent && agent.apiName === apiName);
|
|
118
|
+
return mine.length === 1 ? mine[0].mode : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is the page's own Foldspace agent this project's agent - same Agent Key, same
|
|
123
|
+
* product? Attach's swap-mode preflight answers exactly that, so it is reused.
|
|
124
|
+
*/
|
|
125
|
+
export function checkPublishedPage({ state, agent }) {
|
|
126
|
+
const mode = modeOnPage(state?.agentIds, agent.apiName) || agent.mode;
|
|
127
|
+
const characterization = characterizePageAgents({
|
|
128
|
+
sdkPresent: state?.sdkPresent === true,
|
|
129
|
+
agentIds: state?.agentIds || [],
|
|
130
|
+
expectedApiName: agent.apiName,
|
|
131
|
+
expectedMode: mode,
|
|
132
|
+
});
|
|
133
|
+
const pageProductIds = detectedProductIds(state);
|
|
134
|
+
const guard = guardAttachMode({
|
|
135
|
+
mode: ATTACH_MODES.SWAP,
|
|
136
|
+
characterization,
|
|
137
|
+
expectedProductId: agent.productId,
|
|
138
|
+
pageProductIds,
|
|
139
|
+
});
|
|
140
|
+
if (guard.ok) return { ok: true, mode };
|
|
141
|
+
|
|
142
|
+
const status = characterization.status;
|
|
143
|
+
const next = attachAgain(status === "no-sdk" ? "--bootstrap" : "--replace");
|
|
144
|
+
let reason;
|
|
145
|
+
if (status === "no-sdk") {
|
|
146
|
+
reason = "This page has no Foldspace agent of its own, so there is nothing to load the published code into.";
|
|
147
|
+
} else if (status === "sdk-without-agent") {
|
|
148
|
+
reason = "This page loads Foldspace but has no agent on it.";
|
|
149
|
+
} else if (status === "different-agent") {
|
|
150
|
+
reason = `This page's Foldspace agent is not ${agent.apiName}, the Agent Key in foldspace.dev.json, so foldspace ask would talk to another agent and the check would prove nothing.`;
|
|
151
|
+
} else if (status === "multiple-agents") {
|
|
152
|
+
reason = "This page has more than one Foldspace agent, so it is not clear which one foldspace ask would talk to.";
|
|
153
|
+
} else if (!pageProductIds.length) {
|
|
154
|
+
reason = `The page's agent is ${agent.apiName}, but its product cannot be read from the page, so it is not proven to be this project's agent.`;
|
|
155
|
+
} else {
|
|
156
|
+
reason = `The page's agent is ${agent.apiName} of another product (${pageProductIds.join(", ")}), not ${agent.productId} in foldspace.dev.json: it would load that product's code, and the check would prove nothing.`;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
reason,
|
|
161
|
+
next,
|
|
162
|
+
page: { agents: state?.agentIds || [], products: pageProductIds },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Page body that points the agent instance at its published code and reports
|
|
168
|
+
* what loaded. It calls only the instance's public methods, and watches the
|
|
169
|
+
* one global every published bundle assigns itself to.
|
|
170
|
+
*
|
|
171
|
+
* What in the SDK shapes it:
|
|
172
|
+
* - A published bundle assigns its handlers to window.__FOLDSPACE_REMOTE_ACTIONS__;
|
|
173
|
+
* the SDK reads that global when the script loads, deletes it, and then
|
|
174
|
+
* reports remoteActions.loaded. Watching the global is how this knows the
|
|
175
|
+
* exact handlers the published code brought.
|
|
176
|
+
* - When remote actions load, a handler the instance already has wins over a
|
|
177
|
+
* loaded one of the same name - the page's own, or what its normal load
|
|
178
|
+
* brought, even when that load lands late. So once the published code has
|
|
179
|
+
* loaded, its handlers are set over every one of the same name, and the
|
|
180
|
+
* check counts only the names that answer from the published code.
|
|
181
|
+
* - An agent created late queues these calls until it is ready, and its
|
|
182
|
+
* getAvailableActions() reads {} until then. Nothing is taken from the page
|
|
183
|
+
* before the load: what it had is read only after the load reported.
|
|
184
|
+
* - setConfiguration loads nothing when the settings equal the current ones
|
|
185
|
+
* (the page may already load prod, or ran this before). Turning remote
|
|
186
|
+
* actions off first makes the second call a change.
|
|
187
|
+
* The page remembers what an earlier run brought and what it covered, so dev's
|
|
188
|
+
* handlers do not linger into a prod check.
|
|
189
|
+
*/
|
|
190
|
+
export function buildPublishedScript({ apiName, mode, environment, timeoutMs = LOAD_TIMEOUT_MS }) {
|
|
191
|
+
return `
|
|
192
|
+
const NS = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__ ? window.__FOLD_SPACE__ : "foldspace";
|
|
193
|
+
const sdk = window[NS];
|
|
194
|
+
if (typeof sdk?.agent !== "function") return { switched: false, reason: "no-sdk" };
|
|
195
|
+
const agent = sdk.agent({ apiName: ${JSON.stringify(apiName)}, mode: ${JSON.stringify(mode)} });
|
|
196
|
+
const needed = ["getAvailableActions", "removeAiActions", "setAiActions", "setConfiguration", "on", "off"];
|
|
197
|
+
const missing = needed.filter((name) => typeof agent?.[name] !== "function");
|
|
198
|
+
if (missing.length) return { switched: false, reason: "sdk-too-old", missing };
|
|
199
|
+
|
|
200
|
+
const ENV = ${JSON.stringify(environment.name)};
|
|
201
|
+
const store = window.__FOLDSPACE_PUBLISHED__ || (window.__FOLDSPACE_PUBLISHED__ = {});
|
|
202
|
+
const ID = ${JSON.stringify(`${mode.toLowerCase()}-${apiName}`)};
|
|
203
|
+
// own: the page's handler for a name the published code covered, before
|
|
204
|
+
// any run covered it. brought: what the last run set.
|
|
205
|
+
const memo = store[ID]?.brought ? store[ID] : (store[ID] = { own: {}, brought: {} });
|
|
206
|
+
|
|
207
|
+
const GLOBAL = ${JSON.stringify(REMOTE_ACTIONS_GLOBAL)};
|
|
208
|
+
let held = window[GLOBAL];
|
|
209
|
+
let lastRead;
|
|
210
|
+
let watching = true;
|
|
211
|
+
const watch = {
|
|
212
|
+
configurable: true,
|
|
213
|
+
enumerable: true,
|
|
214
|
+
get() {
|
|
215
|
+
lastRead = held;
|
|
216
|
+
queueMicrotask(rearm);
|
|
217
|
+
return held;
|
|
218
|
+
},
|
|
219
|
+
set(value) { held = value; },
|
|
220
|
+
};
|
|
221
|
+
const armed = () => Object.getOwnPropertyDescriptor(window, GLOBAL)?.get === watch.get;
|
|
222
|
+
// The SDK deletes the global right after reading it; watch for the next load.
|
|
223
|
+
function rearm() {
|
|
224
|
+
if (!watching || armed()) return;
|
|
225
|
+
held = undefined;
|
|
226
|
+
try { Object.defineProperty(window, GLOBAL, watch); } catch {}
|
|
227
|
+
}
|
|
228
|
+
try { Object.defineProperty(window, GLOBAL, watch); } catch {}
|
|
229
|
+
|
|
230
|
+
let settle;
|
|
231
|
+
const outcome = new Promise((resolve) => { settle = resolve; });
|
|
232
|
+
const onLoaded = (event) => {
|
|
233
|
+
// The read that just happened belongs to this load, whichever load it is.
|
|
234
|
+
const bundle = lastRead;
|
|
235
|
+
lastRead = undefined;
|
|
236
|
+
if (event?.environment === ENV) {
|
|
237
|
+
settle({ loaded: true, actionCount: Number(event.actionCount) || 0, source: event.source || null, bundle });
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const onFailed = (event) => {
|
|
241
|
+
if (event?.environment === ENV) settle({ loaded: false, source: event.source || null });
|
|
242
|
+
};
|
|
243
|
+
agent.on("remoteActions.loaded", onLoaded);
|
|
244
|
+
agent.on("remoteActions.failed", onFailed);
|
|
245
|
+
let result;
|
|
246
|
+
let timer;
|
|
247
|
+
try {
|
|
248
|
+
agent.setConfiguration({ remoteActionsSettings: { enabled: false } });
|
|
249
|
+
agent.setConfiguration({
|
|
250
|
+
enableDebugLogs: true,
|
|
251
|
+
remoteActionsSettings: { enabled: true, environment: ${JSON.stringify(environment.sdkValue)} },
|
|
252
|
+
});
|
|
253
|
+
result = await Promise.race([
|
|
254
|
+
outcome,
|
|
255
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve({ loaded: null }), ${Number(timeoutMs)}); }),
|
|
256
|
+
]);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
result = { loaded: false, error: String(error?.message || error).slice(0, 300) };
|
|
259
|
+
} finally {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
agent.off("remoteActions.loaded", onLoaded);
|
|
262
|
+
agent.off("remoteActions.failed", onFailed);
|
|
263
|
+
watching = false;
|
|
264
|
+
if (armed()) {
|
|
265
|
+
delete window[GLOBAL];
|
|
266
|
+
if (held !== undefined) window[GLOBAL] = held;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { bundle, ...report } = result;
|
|
271
|
+
if (result.loaded !== true) return { switched: true, ...report };
|
|
272
|
+
if (!bundle || typeof bundle !== "object") {
|
|
273
|
+
return { switched: true, ...report, published: report.actionCount ? null : [], kept: [] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The SDK takes only entries with an execute function.
|
|
277
|
+
const names = Object.keys(bundle).filter((name) => typeof bundle[name]?.execute === "function");
|
|
278
|
+
const now = agent.getAvailableActions() || {};
|
|
279
|
+
for (const name of names) {
|
|
280
|
+
const current = now[name];
|
|
281
|
+
if (current && current !== bundle[name] && current !== memo.brought[name] && !(name in memo.own)) {
|
|
282
|
+
memo.own[name] = current;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// What an earlier run brought that this code does not define goes; the
|
|
286
|
+
// page's own handler for that name comes back.
|
|
287
|
+
for (const name of Object.keys(memo.brought)) {
|
|
288
|
+
if (names.includes(name) || now[name] !== memo.brought[name]) continue;
|
|
289
|
+
if (name in memo.own) agent.setAiActions({ [name]: memo.own[name] });
|
|
290
|
+
else agent.removeAiActions([name]);
|
|
291
|
+
}
|
|
292
|
+
const over = {};
|
|
293
|
+
for (const name of names) if (now[name] !== bundle[name]) over[name] = bundle[name];
|
|
294
|
+
if (Object.keys(over).length) agent.setAiActions(over);
|
|
295
|
+
memo.brought = Object.fromEntries(names.map((name) => [name, bundle[name]]));
|
|
296
|
+
|
|
297
|
+
const after = agent.getAvailableActions() || {};
|
|
298
|
+
const published = names.filter((name) => after[name] === bundle[name]).sort();
|
|
299
|
+
const kept = names.filter((name) => after[name] !== bundle[name]).sort();
|
|
300
|
+
return { switched: true, ...report, published, kept };`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The published code of an environment is /agent/actions/<env>/<product>/<agent>/index.js.
|
|
305
|
+
* Anything else the agent loaded is not what was published last: an agent
|
|
306
|
+
* pinned to one version loads index.<version>.js (the SDK reports that name
|
|
307
|
+
* even when it falls back to index.js), and a host override loads from
|
|
308
|
+
* elsewhere entirely.
|
|
309
|
+
*/
|
|
310
|
+
export function publishedSourceProblem({ source, env, agent }) {
|
|
311
|
+
if (!source) return null;
|
|
312
|
+
const path = `/agent/actions/${env}/${agent.productId}/${agent.apiName}/`;
|
|
313
|
+
if (source.endsWith(`${path}index.js`)) return null;
|
|
314
|
+
if (!source.includes(`/agent/actions/${env}/`)) {
|
|
315
|
+
return `The agent loaded ${source}, not the published ${word(env)} code: its configuration names its own address for actions.`;
|
|
316
|
+
}
|
|
317
|
+
if (source.includes(path) && /\/index\.[^/]+\.js$/.test(source)) {
|
|
318
|
+
return `The agent loaded ${source}: it is pinned to one version of its code, so this window keeps loading that version, not what was published last.`;
|
|
319
|
+
}
|
|
320
|
+
return `The agent loaded ${source}, not the published ${word(env)} code of ${agent.apiName}.`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The JSON the command prints, from what the page reported. */
|
|
324
|
+
export function publishedReport({ outcome, environment, agent, timeoutMs = LOAD_TIMEOUT_MS }) {
|
|
325
|
+
const env = environment.name;
|
|
326
|
+
const base = { environment: env, agent: agent.apiName };
|
|
327
|
+
if (!outcome?.switched) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
...base,
|
|
331
|
+
reason:
|
|
332
|
+
outcome?.reason === "sdk-too-old"
|
|
333
|
+
? `This page's Foldspace SDK cannot load published code on request: its agent has no ${outcome.missing.join(", ")}.`
|
|
334
|
+
: "This page has no Foldspace agent to point at the published code.",
|
|
335
|
+
next: notHere(env),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (outcome.loaded === null) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
...base,
|
|
342
|
+
reason: `The agent did not report loading the published ${word(env)} code within ${Math.round(timeoutMs / 1000)}s. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
|
|
343
|
+
next: again(env),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (outcome.error) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
...base,
|
|
350
|
+
reason: `This page's agent failed while switching to the published ${word(env)} code: ${outcome.error}. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
|
|
351
|
+
next: notHere(env),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (outcome.loaded === false) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
...base,
|
|
358
|
+
reason: `The page could not load the published ${word(env)} code${outcome.source ? ` (${outcome.source})` : ""}. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
|
|
359
|
+
next:
|
|
360
|
+
env === "dev"
|
|
361
|
+
? `If you have not published to dev with the \`deploy\` tool yet, do that, then run \`npx foldspace published dev\` again. If you have, run it again in a minute; if it fails again: ${lower(notHere(env))}`
|
|
362
|
+
: `If you have published to production with the \`deploy\` tool, run \`npx foldspace published prod\` again in a minute. If you have not (production gets code only with the human's yes), or it fails again: ${lower(notHere(env))}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const sourceProblem = publishedSourceProblem({ source: outcome.source, env, agent });
|
|
366
|
+
if (sourceProblem) return { ok: false, ...base, reason: sourceProblem, next: notHere(env) };
|
|
367
|
+
if (!Array.isArray(outcome.published)) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
...base,
|
|
371
|
+
reason: `The agent loaded the published ${word(env)} code, but the page did not show which handlers it brought, so it is not proven that foldspace ask would test that code.`,
|
|
372
|
+
next: notHere(env),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const kept = outcome.kept || [];
|
|
376
|
+
if (kept.length) {
|
|
377
|
+
const count = outcome.published.length + kept.length;
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
...base,
|
|
381
|
+
reason: `The published ${word(env)} code has ${count} ${count === 1 ? "action" : "actions"}, but the page kept its own handler for ${kept.length} of them (${kept.join(", ")}), so foldspace ask would not test that code.`,
|
|
382
|
+
next: again(env),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
...base,
|
|
388
|
+
actions: outcome.published,
|
|
389
|
+
...(outcome.source ? { source: outcome.source } : {}),
|
|
390
|
+
note: RELOAD_NOTE,
|
|
391
|
+
};
|
|
392
|
+
}
|