create-egregore 0.21.2 → 0.21.4

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.2
2
+ // egregore-launcher-version: 0.21.4
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
35
35
  const { spawnSync, spawn } = require("node:child_process");
36
36
  const readline = require("node:readline");
37
37
 
38
- const VERSION = "0.21.2";
38
+ const VERSION = "0.21.4";
39
39
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
40
40
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
41
41
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -1707,6 +1707,18 @@ function connectedOrganizationId(config, response) {
1707
1707
  return returned || current;
1708
1708
  }
1709
1709
 
1710
+ function validatedConnectedClaim(config, response, expectedOrgIds = []) {
1711
+ const orgId = connectedOrganizationId(config, response);
1712
+ for (const expectedOrgId of expectedOrgIds) {
1713
+ if (expectedOrgId) connectedOrganizationId({ org_id: expectedOrgId }, response);
1714
+ }
1715
+ if (typeof response?.api_url !== "string" || !response.api_url.trim() ||
1716
+ typeof response?.api_key !== "string" || !response.api_key.trim()) {
1717
+ die("Connected did not return usable access settings. Local configuration is unchanged; run the same command to retry.");
1718
+ }
1719
+ return { orgId, apiUrl: response.api_url, apiKey: response.api_key };
1720
+ }
1721
+
1710
1722
  function hostedMemorySyncEnabled(config) {
1711
1723
  const override = process.env.EGREGORE_GRAPH_PROJECTION;
1712
1724
  if (["1", "true", "enabled"].includes(override)) return true;
@@ -1714,6 +1726,25 @@ function hostedMemorySyncEnabled(config) {
1714
1726
  return config.features?.graph_projection === true;
1715
1727
  }
1716
1728
 
1729
+ function connectionHasSubscription(status) {
1730
+ if (typeof status?.entitled === "boolean") return status.entitled;
1731
+ return ["entitled", "provisioning", "backfilling", "connected"].includes(status?.status);
1732
+ }
1733
+
1734
+ function refreshConnectedRuntime(inst) {
1735
+ // Connect is an instance-scoped overlay; never refresh other runtimes here.
1736
+ const result = spawnSync(
1737
+ "npx", ["-y", `${PUBLIC_NPX_PACKAGE}@${VERSION}`, "--refresh-connect"],
1738
+ { cwd: inst.path, encoding: "utf-8", stdio: "pipe", timeout: 3 * 60 * 1000 },
1739
+ );
1740
+ if (result.status === 0) {
1741
+ console.log(` ${GREEN}✓${RESET} Connect features installed`);
1742
+ } else {
1743
+ console.log(` ${YELLOW}!${RESET} Connect features will retry on the next update`);
1744
+ console.log(` ${DIM}npx -y ${PUBLIC_NPX_PACKAGE}@${VERSION} --refresh-connect${RESET}`);
1745
+ }
1746
+ }
1747
+
1717
1748
  async function cmdConnect(args) {
1718
1749
  const fresh = args.includes("--fresh");
1719
1750
  const positional = args.filter((a) => !String(a).startsWith("--"));
@@ -1733,7 +1764,7 @@ async function cmdConnect(args) {
1733
1764
  const activation = state.connected_activation || {};
1734
1765
  let expectedOrgId = cfg.org_id && !String(cfg.org_id).startsWith("legacy-org:")
1735
1766
  ? cfg.org_id : activation.org_id || "";
1736
- if (cfg.mode === "connected" && activation.status === "connected") {
1767
+ if (cfg.mode === "connected" && activation.status === "connected" && activation.intent_id) {
1737
1768
  if (activation.intent_id) {
1738
1769
  const repaired = await connectedApiRequest(
1739
1770
  githubToken,
@@ -1746,16 +1777,14 @@ async function cmdConnect(args) {
1746
1777
  `Connected is active, but its shared configuration could not be published: ${connectError(repaired, `HTTP ${repaired.status}`)}`,
1747
1778
  );
1748
1779
  }
1749
- const orgId = connectedOrganizationId(cfg, repaired.data);
1780
+ const access = validatedConnectedClaim(cfg, repaired.data, [activation.org_id]);
1750
1781
  writeJsonFileAtomic(path.join(inst.path, "egregore.json"), {
1751
1782
  ...cfg,
1752
- ...(orgId ? { org_id: orgId } : {}),
1783
+ org_id: access.orgId,
1753
1784
  mode: "connected",
1754
- api_url: repaired.data.api_url,
1785
+ api_url: access.apiUrl,
1755
1786
  });
1756
- if (repaired.data.api_key) {
1757
- writeEnvValueAtomic(path.join(inst.path, ".env"), "EGREGORE_API_KEY", repaired.data.api_key);
1758
- }
1787
+ writeEnvValueAtomic(path.join(inst.path, ".env"), "EGREGORE_API_KEY", access.apiKey);
1759
1788
  }
1760
1789
  normalizeClaudeDenyRules(inst.path);
1761
1790
  ensureClaudeModeStatusLine(inst.path);
@@ -1766,18 +1795,80 @@ async function cmdConnect(args) {
1766
1795
 
1767
1796
  console.log("");
1768
1797
  console.log(` ${BOLD}CONNECT${RESET} ${DIM}${inst.slug}${RESET}`);
1769
- console.log(` ${DIM}€500/month · Stripe checkout · existing Markdown and Git stay in place${RESET}`);
1798
+ console.log(` ${DIM}Connect this installation · existing Markdown and Git stay in place${RESET}`);
1770
1799
  console.log("");
1771
1800
 
1772
1801
  let intentId = activation.intent_id || "";
1773
1802
  let connectUrl = activation.connect_url || "";
1774
1803
  let ticketExpiresAt = Number(activation.connect_ticket_expires_at || 0) * 1000;
1775
- const resumable = ["created", "checkout_pending", "entitled", "provisioning", "backfilling"].includes(
1804
+ let checkoutRequired = true;
1805
+ const resumable = ["created", "checkout_pending", "entitled", "provisioning", "backfilling", "connected"].includes(
1776
1806
  activation.status,
1777
1807
  );
1808
+ const connectRequest = {
1809
+ org_slug: cfg.slug || inst.slug,
1810
+ plan: "connect",
1811
+ instance_metadata: {
1812
+ launcher_version: VERSION,
1813
+ mode: cfg.mode || "local",
1814
+ repo_name: cfg.repo_name || "",
1815
+ managed_repo_count: Array.isArray(cfg.repos) ? cfg.repos.length : 0,
1816
+ hosted_memory_sync: hostedMemorySyncEnabled(cfg),
1817
+ fresh,
1818
+ },
1819
+ };
1820
+ if (intentId && resumable) {
1821
+ const current = await connectedApiRequest(
1822
+ githubToken, "GET", `/api/billing/connect/${encodeURIComponent(intentId)}`,
1823
+ );
1824
+ if (current.status !== 200) {
1825
+ die(`could not check the existing connection: ${connectError(current, `HTTP ${current.status}`)}. Run the same command to retry.`);
1826
+ }
1827
+ checkoutRequired = !connectionHasSubscription(current.data);
1828
+ if (checkoutRequired && ["entitled", "provisioning", "backfilling", "connected"].includes(current.data?.status)) {
1829
+ die("Your subscription is not active. Check its billing status before reconnecting this installation.");
1830
+ }
1831
+ if (current.data?.entitled === true &&
1832
+ [activation.status, current.data.status].some(value => ["created", "checkout_pending"].includes(value))) {
1833
+ if (activation.org_id && !String(activation.org_id).startsWith("legacy-org:")) {
1834
+ connectedOrganizationId(cfg, { org_id: activation.org_id });
1835
+ }
1836
+ if (current.data.org_slug && current.data.org_slug !== connectRequest.org_slug) {
1837
+ die("The saved connection belongs to another Egregore. Local configuration is unchanged.");
1838
+ }
1839
+ // Retry this saved attempt with the current target and explicit optional
1840
+ // sync choice. No local writes until the API confirms a claimable intent.
1841
+ const retryKey = crypto.createHash("sha256").update(JSON.stringify([
1842
+ intentId, connectRequest.org_slug, connectRequest.instance_metadata.repo_name,
1843
+ connectRequest.instance_metadata.hosted_memory_sync,
1844
+ ])).digest("hex");
1845
+ const recovered = await connectedApiRequest(
1846
+ githubToken, "POST", "/api/billing/connect-intents", connectRequest,
1847
+ { "X-Idempotency-Key": `cli:recover:${retryKey}`, "X-Egregore-Connect-Recovery": "1" },
1848
+ );
1849
+ if (recovered.status !== 200) {
1850
+ die(`could not recover the saved connection: ${connectError(recovered, `HTTP ${recovered.status}`)}. Run the same command to retry.`);
1851
+ }
1852
+ const result = recovered.data;
1853
+ if (typeof result?.connection_intent_id !== "string" || !result.connection_intent_id.trim() ||
1854
+ result.entitled !== true || result.checkout_required !== false ||
1855
+ !["entitled", "provisioning", "backfilling", "connected"].includes(result.status) ||
1856
+ (result.org_slug && result.org_slug !== connectRequest.org_slug)) {
1857
+ die("The saved connection could not be recovered without a new purchase. Check its billing status, then retry; your saved activation is unchanged.");
1858
+ }
1859
+ intentId = result.connection_intent_id;
1860
+ checkoutRequired = false;
1861
+ connectUrl = "";
1862
+ persistConnectState(inst, {
1863
+ intent_id: intentId, status: result.status, checkout_required: false,
1864
+ connect_url: "", connect_ticket: undefined, connect_ticket_expires_at: undefined,
1865
+ });
1866
+ }
1867
+ }
1778
1868
  if (
1779
1869
  intentId &&
1780
1870
  resumable &&
1871
+ checkoutRequired &&
1781
1872
  (!activation.connect_ticket || !ticketExpiresAt || ticketExpiresAt <= Date.now() + 60_000)
1782
1873
  ) {
1783
1874
  const refreshed = await connectedApiRequest(
@@ -1822,31 +1913,22 @@ async function cmdConnect(args) {
1822
1913
  githubToken,
1823
1914
  "POST",
1824
1915
  "/api/billing/connect-intents",
1825
- {
1826
- org_slug: cfg.slug || inst.slug,
1827
- plan: "connect",
1828
- instance_metadata: {
1829
- launcher_version: VERSION,
1830
- mode: cfg.mode || "local",
1831
- repo_name: cfg.repo_name || "",
1832
- managed_repo_count: Array.isArray(cfg.repos) ? cfg.repos.length : 0,
1833
- hosted_memory_sync: hostedMemorySyncEnabled(cfg),
1834
- fresh,
1835
- },
1836
- },
1837
- { "X-Idempotency-Key": `cli:${inst.slug}:${crypto.randomUUID()}` },
1916
+ connectRequest,
1917
+ { "X-Idempotency-Key": `cli:${inst.slug}:${crypto.randomUUID()}`, "X-Egregore-Connect-Recovery": "1" },
1838
1918
  );
1839
1919
  if (checkout.status !== 200) {
1840
- die(`could not start Connected checkout: ${connectError(checkout, `HTTP ${checkout.status}`)}`);
1920
+ die(`could not connect this installation: ${connectError(checkout, `HTTP ${checkout.status}`)}`);
1841
1921
  }
1842
1922
  intentId = checkout.data.connection_intent_id;
1843
- connectUrl = checkout.data.connect_url;
1923
+ connectUrl = checkout.data.connect_url || "";
1924
+ checkoutRequired = checkout.data.checkout_required !== false && !connectionHasSubscription(checkout.data);
1844
1925
  persistConnectState(inst, {
1845
1926
  intent_id: intentId,
1846
1927
  org_id: expectedOrgId,
1847
1928
  connect_ticket: checkout.data.connect_ticket,
1848
1929
  connect_ticket_expires_at: checkout.data.connect_ticket_expires_at,
1849
1930
  connect_url: connectUrl,
1931
+ checkout_required: checkoutRequired,
1850
1932
  status: checkout.data.status || "created",
1851
1933
  });
1852
1934
  console.log(` [1/5] ${GREEN}✓${RESET} Connection intent`);
@@ -1865,16 +1947,16 @@ async function cmdConnect(args) {
1865
1947
  console.log(` [1/5] ${GREEN}✓${RESET} Resuming connection intent`);
1866
1948
  }
1867
1949
 
1868
- if (activation.status !== "backfilling") {
1950
+ if (checkoutRequired) {
1869
1951
  if (connectUrl) {
1870
1952
  const opened = openExternalUrl(connectUrl);
1871
1953
  const mark = opened ? `${GREEN}✓${RESET}` : `${YELLOW}!${RESET}`;
1872
1954
  console.log(` [2/5] ${mark} ${opened ? "Opened" : "Open"} Egregore Connect`);
1873
1955
  console.log(` ${CYAN}${connectUrl}${RESET}`);
1874
1956
  }
1875
- console.log(` ${DIM}Complete checkout in the browser. Promotion codes are entered in Stripe.${RESET}`);
1957
+ console.log(` ${DIM}New subscription: €500/month. Complete checkout in the browser; promotion codes are entered in Stripe.${RESET}`);
1876
1958
  } else {
1877
- console.log(` [2/5] ${GREEN}✓${RESET} Subscription already confirmed`);
1959
+ console.log(` [2/5] ${GREEN}✓${RESET} Existing subscription found · no new purchase needed`);
1878
1960
  }
1879
1961
 
1880
1962
  const pollMs = Math.max(100, Number(process.env.EGREGORE_CONNECT_POLL_INTERVAL_MS || 2000));
@@ -1889,10 +1971,10 @@ async function cmdConnect(args) {
1889
1971
  );
1890
1972
  if (result.status === 200) {
1891
1973
  status = result.data;
1892
- if (
1893
- status.entitled ||
1894
- ["entitled", "provisioning", "backfilling", "connected"].includes(status.status)
1895
- ) break;
1974
+ if (connectionHasSubscription(status)) break;
1975
+ if (["entitled", "provisioning", "backfilling", "connected"].includes(status.status)) {
1976
+ die("Your subscription is not active. Check its billing status before reconnecting this installation.");
1977
+ }
1896
1978
  if (["canceled", "failed"].includes(status.status)) {
1897
1979
  persistConnectState(inst, { status: status.status });
1898
1980
  die(`Connected checkout ${status.status}. Your local Egregore was not changed.`);
@@ -1902,7 +1984,7 @@ async function cmdConnect(args) {
1902
1984
  }
1903
1985
  await sleep(pollMs);
1904
1986
  }
1905
- if (!status || !(status.entitled || ["entitled", "provisioning", "backfilling", "connected"].includes(status.status))) {
1987
+ if (!connectionHasSubscription(status)) {
1906
1988
  die("timed out waiting for checkout. Run the same command to resume.");
1907
1989
  }
1908
1990
  console.log(` [3/5] ${GREEN}✓${RESET} Subscription confirmed`);
@@ -1921,17 +2003,25 @@ async function cmdConnect(args) {
1921
2003
  const configFile = path.join(inst.path, "egregore.json");
1922
2004
  const envFile = path.join(inst.path, ".env");
1923
2005
  const currentConfig = readJsonFile(configFile);
1924
- const orgId = connectedOrganizationId({ ...currentConfig, org_id: expectedOrgId || currentConfig.org_id }, claim.data);
2006
+ const access = validatedConnectedClaim(currentConfig, claim.data, [expectedOrgId, activation.org_id]);
1925
2007
  const syncEnabled = hostedMemorySyncEnabled(currentConfig);
1926
2008
  writeJsonFileAtomic(configFile, {
1927
2009
  ...currentConfig,
1928
- ...(orgId ? { org_id: orgId } : {}),
2010
+ org_id: access.orgId,
1929
2011
  mode: "connected",
1930
- api_url: claim.data.api_url,
2012
+ api_url: access.apiUrl,
1931
2013
  });
1932
- writeEnvValueAtomic(envFile, "EGREGORE_API_KEY", claim.data.api_key);
2014
+ writeEnvValueAtomic(envFile, "EGREGORE_API_KEY", access.apiKey);
1933
2015
  normalizeClaudeDenyRules(inst.path);
1934
2016
  ensureClaudeModeStatusLine(inst.path);
2017
+ if (claim.data.status === "connected") {
2018
+ persistConnectState(inst, { status: "connected", activation_protocol: claim.data.activation_protocol });
2019
+ console.log(` [4/5] ${GREEN}✓${RESET} Access restored for this installation`);
2020
+ console.log(` [5/5] ${GREEN}✓${RESET} Existing connection restored · memory sync was not restarted`);
2021
+ refreshConnectedRuntime(inst);
2022
+ console.log(`\n ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is connected. Local files remain the source of truth.\n`);
2023
+ return;
2024
+ }
1935
2025
  persistConnectState(inst, {
1936
2026
  status: "backfilling",
1937
2027
  activation_protocol: claim.data.activation_protocol,
@@ -1991,23 +2081,7 @@ async function cmdConnect(args) {
1991
2081
  console.log(` ${DIM}${line}${RESET}`);
1992
2082
  }
1993
2083
  }
1994
- // Connect is an instance-scoped overlay; never refresh other runtimes here.
1995
- const runtimeRefresh = spawnSync(
1996
- "npx",
1997
- ["-y", `${PUBLIC_NPX_PACKAGE}@${VERSION}`, "--refresh-connect"],
1998
- {
1999
- cwd: inst.path,
2000
- encoding: "utf-8",
2001
- stdio: "pipe",
2002
- timeout: 3 * 60 * 1000,
2003
- },
2004
- );
2005
- if (runtimeRefresh.status === 0) {
2006
- console.log(` ${GREEN}✓${RESET} Connect features installed`);
2007
- } else {
2008
- console.log(` ${YELLOW}!${RESET} Connect features will retry on the next update`);
2009
- console.log(` ${DIM}npx -y ${PUBLIC_NPX_PACKAGE}@${VERSION} --refresh-connect${RESET}`);
2010
- }
2084
+ refreshConnectedRuntime(inst);
2011
2085
  console.log("");
2012
2086
  console.log(` ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is connected. Local files remain the source of truth.`);
2013
2087
  console.log("");
@@ -3562,6 +3636,13 @@ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCurs
3562
3636
  }
3563
3637
  } else {
3564
3638
  const upgradeValue = runtimeUpdateRowValue(upgrade, candidate);
3639
+ const hostingReadiness = d.hosting === false ? "disabled" : d.hosting_status?.readiness;
3640
+ const hostingLabels = {
3641
+ disabled: `${YELLOW}OFF${RESET}`,
3642
+ connected_configured: `${GREEN}ACCESS CONFIGURED${RESET}`,
3643
+ public_relay: `${YELLOW}PUBLIC RELAY${RESET}`,
3644
+ setup_incomplete: `${YELLOW}SETUP INCOMPLETE${RESET}`,
3645
+ };
3565
3646
  const rows = [
3566
3647
  {
3567
3648
  name: "Runtime update",
@@ -3571,7 +3652,7 @@ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCurs
3571
3652
  },
3572
3653
  { name: "Privacy & access", value: `${DIM}identity · boundary · consent${RESET}` },
3573
3654
  { name: "Telemetry", value: `${DIM}personal · opt-out${RESET}` },
3574
- { name: "Hosted Artifacts", value: (d.hosting !== false) ? `${GREEN}ON${RESET}` : `${YELLOW}OFF${RESET}` },
3655
+ { name: "Hosted Artifacts", value: hostingLabels[hostingReadiness] || `${YELLOW}STATUS UNKNOWN${RESET}` },
3575
3656
  {
3576
3657
  name: "Git workflow",
3577
3658
  value: d.workflow === "simple"
@@ -3591,6 +3672,21 @@ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCurs
3591
3672
  lines.push(boxLine(`${marker} ${pad(name, 20)} ${row.value}`, innerWidth, margin));
3592
3673
  });
3593
3674
  lines.push(emptyBoxLine(innerWidth, margin));
3675
+ if (cursor === 3) {
3676
+ const hostingHints = {
3677
+ disabled: ["Publishing is turned off."],
3678
+ connected_configured: ["Access is configured. Connection not checked."],
3679
+ public_relay: ["Pages are public without sign-in and expire after 7 days."],
3680
+ setup_incomplete: [
3681
+ "Publishing is enabled; connection setup is incomplete.",
3682
+ `Run ${d.hosting_status?.recovery_command || `egregore connect ${inst.slug}`}`,
3683
+ ],
3684
+ };
3685
+ for (const hint of hostingHints[hostingReadiness] || ["Update this Egregore to check publishing readiness."]) {
3686
+ lines.push(boxLine(`${DIM}${hint}${RESET}`, innerWidth, margin));
3687
+ }
3688
+ lines.push(emptyBoxLine(innerWidth, margin));
3689
+ }
3594
3690
  if (notice) lines.push(boxLine(notice, innerWidth, margin));
3595
3691
  lines.push(boxLine(`${DIM}↑↓ nav · ${RESET}${BOLD}enter${RESET}${DIM} ${cursor === 3 ? "toggle" : "open"} · ← back · q quit${RESET}`, innerWidth, margin));
3596
3692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
4
4
  "description": "Set up Egregore for your team in one command \u2014 installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -153,51 +153,30 @@ On the first session where telemetry events are emitted, if `telemetry_noticed`
153
153
 
154
154
  Then set `telemetry_noticed: true` in the state file. Never repeat this notice.
155
155
 
156
- ## Session Reports
157
-
158
- Separate from telemetry. Users can optionally share session reports during `/wrap` or via `/issue egregore:`. Reports are **opt-in per session** — the user is asked each time and must explicitly agree.
159
-
160
- ### How it works
161
-
162
- `bin/session-report.sh` handles report submission. It POSTs directly to Supabase via the anon key (no API server needed). The agent generates a structured report from the session context inline — no separate LLM call.
163
-
164
- ```bash
165
- # Submit a report (reads JSON from stdin)
166
- echo '{"topic":"...","summary":"..."}' | bash bin/session-report.sh submit
167
-
168
- # Check reporting status
169
- bash bin/session-report.sh status
170
- ```
171
-
172
- ### What is sent
173
-
174
- - AI-analyzed topic + summary (same as what's written to memory)
175
- - Gap analysis: `{type, detail}` where type is `missing_skill`, `missing_tool`, `repeated_failure`, `wrong_info`, or `confusing_ux`
176
- - User's own description (if provided)
177
- - System info: mode, platform, shell, framework version
178
- - Session duration and message count
179
-
180
- ### What is NEVER sent
181
-
182
- - Code, file contents, or file paths
183
- - Conversation transcript or user prompts
184
- - Environment variables or secrets
185
- - Org-specific data (sanitized before sending — org names, person names, tokens replaced)
186
-
187
- ### Opt-out
188
-
189
- - `EGREGORE_NO_REPORTS=1` in `.env`
190
- - `.egregore-state.json` → `"session_reports": false`
191
- - Simply answer "No thanks" when prompted during `/wrap`
192
- - `DO_NOT_TRACK=1` also disables reports
193
-
194
- ### Transport
195
-
196
- - Direct to Supabase via anon key (INSERT-only RLS policy)
197
- - If network fails: saved locally to `~/.egregore/reports/` as fallback
198
- - Telegram notification to maintainers fires server-side via Supabase DB webhook
199
- - Optional: GitHub issue creation on `egregore-labs/egregore` (user chooses each time)
200
-
201
- ### Configuration
202
-
203
- Reports require `report_url` and `report_key` in `egregore.json`. New OSS installations get these automatically via `create-egregore`. If missing, the report prompt is skipped entirely.
156
+ ## Framework support and legacy session feedback
157
+
158
+ For an Egregore bug, including the historical `/issue egregore:` wording, use
159
+ the [issue workflow](../skills/issue/SKILL.md). Connected instances prepare an
160
+ exact private support preview and submit only after confirmation. Local users
161
+ can explicitly choose a public upstream report. An internal issue stays within
162
+ its organization's memory or configured tracker; it does not automatically
163
+ reach Egregore support. `/wrap` does not collect or submit session feedback.
164
+
165
+ The older `bin/session-report.sh` is a feedback-storage compatibility helper,
166
+ not a support-reporting route. It inserts selected feedback into `session_reports`
167
+ when `report_url` and `report_key` are configured. A `submitted` receipt means
168
+ only that storage accepted the feedback: no GitHub issue was created and no
169
+ Telegram message was sent. The authenticated database callback acknowledges
170
+ storage without planning or dispatching a notification to a named teammate.
171
+ Transport failure saves feedback locally under `~/.egregore/reports/`; that
172
+ does not establish remote storage or delivery. Missing configuration skips
173
+ submission.
174
+
175
+ Never invoke legacy feedback automatically, generate it from a transcript, or
176
+ use it as a fallback when private support fails. Only explicitly selected and
177
+ reviewed content may be shared. The helper adds legacy user and organization
178
+ labels; it does not guarantee anonymization or scrub arbitrary input.
179
+
180
+ Legacy feedback can be disabled with `EGREGORE_NO_REPORTS=1` or
181
+ `DO_NOT_TRACK=1` in the process environment, or `"session_reports": false` in
182
+ `.egregore-state.json`. These switches do not grant consent to submit content.
@@ -67,6 +67,12 @@ its default recipient is **Egregore support, private**. Local/OSS users may
67
67
  explicitly choose `upstream` for a **public** report. Connected users can also
68
68
  choose public upstream explicitly. Never fall back from private to public.
69
69
 
70
+ Treat the historical `egregore:` prefix as framework-support intent and use
71
+ this reviewed reporting flow. Never route a framework bug through the
72
+ legacy session-feedback helper: it only stores feedback and does not
73
+ file a support issue or send a Telegram notification. Being Connected does not
74
+ turn an internal issue in the customer's memory into a report to Egregore.
75
+
70
76
  Use `'{title}'` for the concise title derived from the user's report and
71
77
  `'{report}'` for the report text the user supplied or reviewed.
72
78
  Write an embedded single quote as `'\''` in each substituted value.
@@ -119,6 +125,13 @@ review the private issue in its Project/Archive. Keep a failed/uncertain report'
119
125
  request ID; no backend failure authorizes public publication or a fresh blind
120
126
  submission.
121
127
 
128
+ Report the confirmed outcome precisely: a preview is not submitted; a local
129
+ issue is saved within this organization; only a matching private receipt with
130
+ `submission_state: filed` means filed with Egregore support. Other states need
131
+ recovery using the same request ID. A filed issue does not prove Telegram
132
+ delivery: `notification_state: unverified` must never be described as sent or
133
+ received. Only a separate delivery receipt can establish that.
134
+
122
135
  Public reports use the reporter's GitHub authentication and return a public
123
136
  issue URL. Destination comes only from HTTPS GitHub `upstream_url`; absent means
124
137
  `egregore-labs/egregore`, `none` means no upstream. Do not infer it from remotes.
@@ -56,7 +56,7 @@ _control_plane_push() {
56
56
  local api_url api_key payload actor response revision
57
57
  api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
58
58
  [ -n "$api_url" ] || return 0
59
- api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2-)
59
+ api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)
60
60
  [ -n "$api_key" ] || return 0
61
61
  actor=$(jq -r '.github_username // empty' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null)
62
62
  payload=$(jq -c --arg by "$actor" '{
@@ -104,22 +104,78 @@ EOF
104
104
 
105
105
  # ── verbs ────────────────────────────────────────────────────────────────
106
106
 
107
+ _hosting_status() {
108
+ # Read local configuration only. A saved key is not proof that the hosted
109
+ # service can be reached or that the credential is still accepted.
110
+ local api_key="" has_key=false slug recovery="egregore connect"
111
+ if [ -r "$SCRIPT_DIR/.env" ]; then
112
+ api_key=$(sed -n 's/^EGREGORE_API_KEY=//p' "$SCRIPT_DIR/.env" | tail -n 1)
113
+ api_key="${api_key#"${api_key%%[![:space:]]*}"}"
114
+ api_key="${api_key%"${api_key##*[![:space:]]}"}"
115
+ case "$api_key" in
116
+ \"*\"|\'*\') api_key="${api_key:1:${#api_key}-2}" ;;
117
+ esac
118
+ [ -n "${api_key//[[:space:]]/}" ] && has_key=true
119
+ fi
120
+ # Match publish-artifact.sh's route selection separately from the saved
121
+ # configuration check. Raw nonempty overrides (even malformed ones) select
122
+ # organisation upload, so they must never imply the public relay's expiry.
123
+ local effective_url="${EGREGORE_API_URL:-}" effective_key="${EGREGORE_API_KEY:-}" org_route=false
124
+ if [ -f "$SCRIPT_DIR/.env" ]; then
125
+ effective_url="${effective_url:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
126
+ effective_key="${effective_key:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
127
+ fi
128
+ effective_url="${effective_url:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
129
+ if [ -n "$effective_url" ] && [ -n "$effective_key" ]; then org_route=true; fi
130
+ slug=$(jq -r '.slug // empty' "$CONFIG")
131
+ if _valid_token "$slug"; then recovery="egregore connect $slug"; fi
132
+ jq --argjson has_key "$has_key" --argjson org_route "$org_route" --arg recovery "$recovery" '
133
+ def nonblank: if type == "string" then test("\\S") else false end;
134
+ (.features.publishing != false) as $publishing |
135
+ (.features.public_relay == true) as $relay |
136
+ (((if .mode == null then "connected" else .mode end) == "connected") and
137
+ (.api_url | nonblank) and $has_key) as $configured |
138
+ (if $publishing == false then "disabled"
139
+ elif $configured then "connected_configured"
140
+ elif $org_route then "setup_incomplete"
141
+ elif $relay then "public_relay"
142
+ else "setup_incomplete" end) as $readiness |
143
+ {
144
+ domain: "hosting",
145
+ publishing: $publishing,
146
+ readiness: $readiness,
147
+ connection_configured: $configured,
148
+ public_relay: $relay,
149
+ connectivity: "not_checked",
150
+ recovery_command: (if $readiness == "setup_incomplete" then $recovery else null end)
151
+ }' "$CONFIG"
152
+ }
153
+
107
154
  cmd_hosting() {
108
155
  local action="${1:-status}"
109
- # NEVER use jq's `// "true"` here: `//` treats an explicit `false` as empty and
110
- # would return the default, silently defeating the off-switch. Test `!= false`
111
- # so absent/true => enabled, explicit false => disabled. `enabled` is the
112
- # resolved boolean string ("true"/"false").
113
- local enabled; enabled="$(jq -r '.features.publishing != false' "$CONFIG" 2>/dev/null || echo "true")"
114
156
  case "$action" in
115
157
  status)
158
+ local status; status="$(_hosting_status)"
116
159
  if [ "$JSON" = 1 ]; then
117
- jq -n --argjson on "$enabled" '{domain:"hosting", publishing:$on}'
118
- elif [ "$enabled" = "false" ]; then echo "Hosting (egregore.xyz): OFF"
119
- else echo "Hosting (egregore.xyz): ON"; fi ;;
160
+ printf '%s\n' "$status"
161
+ else
162
+ case "$(printf '%s' "$status" | jq -r '.readiness')" in
163
+ disabled) echo "Hosting (egregore.xyz): OFF" ;;
164
+ connected_configured)
165
+ echo "Hosting (egregore.xyz): ACCESS CONFIGURED"
166
+ echo " Connection not checked." ;;
167
+ public_relay)
168
+ echo "Hosting (egregore.xyz): PUBLIC RELAY"
169
+ echo " Anyone with the link can read published artifacts for 7 days. Connection not checked." ;;
170
+ setup_incomplete)
171
+ echo "Hosting (egregore.xyz): SETUP INCOMPLETE"
172
+ echo " Publishing is enabled; connection setup is incomplete."
173
+ echo " Run: $(printf '%s' "$status" | jq -r '.recovery_command')" ;;
174
+ esac
175
+ fi ;;
120
176
  on)
121
177
  _save '.features.publishing = true'
122
- echo "Hosting (egregore.xyz): ON" ;;
178
+ JSON=0 cmd_hosting status ;;
123
179
  off)
124
180
  _save '.features.publishing = false'
125
181
  echo "Hosting (egregore.xyz): OFF — artifacts will no longer upload to egregore.xyz" ;;
@@ -378,10 +434,12 @@ cmd_people() {
378
434
  cmd_dump() {
379
435
  # Always JSON — the launcher calls this once to render the whole settings screen.
380
436
  local people; people="$(_people_json)"
381
- jq --argjson people "$people" '{
437
+ local hosting_status; hosting_status="$(_hosting_status)"
438
+ jq --argjson people "$people" --argjson hosting_status "$hosting_status" '{
382
439
  org: .org_name,
383
440
  slug: .slug,
384
441
  hosting: (.features.publishing != false),
442
+ hosting_status: $hosting_status,
385
443
  public_relay: (.features.public_relay == true),
386
444
  workflow: (
387
445
  if ((if has("base_branch") then .base_branch else "develop" end) == "main") then "simple"
@@ -21,6 +21,26 @@ from .policy import scope_allows_path
21
21
  KINDS = {"bug", "improvement", "question"}
22
22
  AREAS = {"runtime", "onboarding", "connectors", "site", "other"}
23
23
  SEVERITIES = {"critical", "high", "normal"}
24
+ SUPPORT_STATES = {"filed", "failed", "submitting", "uncertain"}
25
+
26
+
27
+ def support_receipt(result, identifier):
28
+ """Accept only a receipt for this request; filing never proves notification."""
29
+ if (not isinstance(result, dict) or result.get('request_id') != identifier
30
+ or not isinstance(result.get('submission_state'), str)
31
+ or result['submission_state'] not in SUPPORT_STATES):
32
+ raise ValueError(f"private support returned an invalid receipt; keep request {identifier} and use report-status")
33
+ state = result['submission_state']
34
+ notices = {
35
+ 'filed': 'Filed with Egregore support. Telegram delivery has not been verified.',
36
+ 'failed': 'Report preserved; support filing failed. Retry this same request.',
37
+ 'submitting': 'Report preserved; support filing is not confirmed. Keep this request ID and check report-status.',
38
+ 'uncertain': 'Report preserved; support filing needs reconciliation. Keep this request ID and check report-status.',
39
+ }
40
+ return {'request_id': identifier, 'submission_state': state,
41
+ 'retryable': state == 'failed',
42
+ 'reconciliation_needed': state in {'submitting', 'uncertain'},
43
+ 'notification_state': 'unverified', 'notice': notices[state]}
24
44
 
25
45
 
26
46
  def _scrub(value, *, public=False):
@@ -207,18 +227,29 @@ class FrameworkReporting:
207
227
  return {'request_id':identifier,'status':result['status'],'github_url':result['github_url']}
208
228
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
209
229
  state['status'] = 'submitting'; GitHubIssues._save(path,state)
210
- code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
211
- {'request_id':identifier, **state['payload']},credential=credential)
212
- if code >= 400:
230
+ try:
231
+ code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
232
+ {'request_id':identifier, **state['payload']},credential=credential)
233
+ except OSError:
234
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
235
+ raise OSError(f"private support response unavailable; keep request {identifier} and use report-status or confirm this same preview") from None
236
+ if not 200 <= code < 300:
213
237
  state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
214
238
  raise ValueError(f"private support request {identifier} needs recovery (HTTP {code}); use report-status or confirm this same preview")
215
- state['status'] = result.get('submission_state','unknown'); GitHubIssues._save(path,state)
216
- return result
239
+ try:
240
+ receipt = support_receipt(result, identifier)
241
+ except ValueError:
242
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
243
+ raise
244
+ state['status'] = receipt['submission_state']; GitHubIssues._save(path,state)
245
+ if state['status'] != 'filed':
246
+ raise ValueError(f"private support request {identifier} needs recovery ({state['status']}); use report-status or confirm this same preview")
247
+ return receipt
217
248
 
218
249
  def status(self, identifier):
219
250
  path, state = self._load('report',identifier,write=False)
220
251
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
221
252
  code, result = self.api(self.root,state['destination'],f'/api/v1/support-reports/{identifier}',credential=credential)
222
- if code >= 400:
253
+ if not 200 <= code < 300:
223
254
  raise ValueError(f"private support status unavailable (HTTP {code}); keep request {identifier}")
224
- return result
255
+ return support_receipt(result, identifier)
@@ -56,7 +56,7 @@ _control_plane_push() {
56
56
  local api_url api_key payload actor response revision
57
57
  api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
58
58
  [ -n "$api_url" ] || return 0
59
- api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2-)
59
+ api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)
60
60
  [ -n "$api_key" ] || return 0
61
61
  actor=$(jq -r '.github_username // empty' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null)
62
62
  payload=$(jq -c --arg by "$actor" '{
@@ -104,22 +104,78 @@ EOF
104
104
 
105
105
  # ── verbs ────────────────────────────────────────────────────────────────
106
106
 
107
+ _hosting_status() {
108
+ # Read local configuration only. A saved key is not proof that the hosted
109
+ # service can be reached or that the credential is still accepted.
110
+ local api_key="" has_key=false slug recovery="egregore connect"
111
+ if [ -r "$SCRIPT_DIR/.env" ]; then
112
+ api_key=$(sed -n 's/^EGREGORE_API_KEY=//p' "$SCRIPT_DIR/.env" | tail -n 1)
113
+ api_key="${api_key#"${api_key%%[![:space:]]*}"}"
114
+ api_key="${api_key%"${api_key##*[![:space:]]}"}"
115
+ case "$api_key" in
116
+ \"*\"|\'*\') api_key="${api_key:1:${#api_key}-2}" ;;
117
+ esac
118
+ [ -n "${api_key//[[:space:]]/}" ] && has_key=true
119
+ fi
120
+ # Match publish-artifact.sh's route selection separately from the saved
121
+ # configuration check. Raw nonempty overrides (even malformed ones) select
122
+ # organisation upload, so they must never imply the public relay's expiry.
123
+ local effective_url="${EGREGORE_API_URL:-}" effective_key="${EGREGORE_API_KEY:-}" org_route=false
124
+ if [ -f "$SCRIPT_DIR/.env" ]; then
125
+ effective_url="${effective_url:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
126
+ effective_key="${effective_key:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
127
+ fi
128
+ effective_url="${effective_url:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
129
+ if [ -n "$effective_url" ] && [ -n "$effective_key" ]; then org_route=true; fi
130
+ slug=$(jq -r '.slug // empty' "$CONFIG")
131
+ if _valid_token "$slug"; then recovery="egregore connect $slug"; fi
132
+ jq --argjson has_key "$has_key" --argjson org_route "$org_route" --arg recovery "$recovery" '
133
+ def nonblank: if type == "string" then test("\\S") else false end;
134
+ (.features.publishing != false) as $publishing |
135
+ (.features.public_relay == true) as $relay |
136
+ (((if .mode == null then "connected" else .mode end) == "connected") and
137
+ (.api_url | nonblank) and $has_key) as $configured |
138
+ (if $publishing == false then "disabled"
139
+ elif $configured then "connected_configured"
140
+ elif $org_route then "setup_incomplete"
141
+ elif $relay then "public_relay"
142
+ else "setup_incomplete" end) as $readiness |
143
+ {
144
+ domain: "hosting",
145
+ publishing: $publishing,
146
+ readiness: $readiness,
147
+ connection_configured: $configured,
148
+ public_relay: $relay,
149
+ connectivity: "not_checked",
150
+ recovery_command: (if $readiness == "setup_incomplete" then $recovery else null end)
151
+ }' "$CONFIG"
152
+ }
153
+
107
154
  cmd_hosting() {
108
155
  local action="${1:-status}"
109
- # NEVER use jq's `// "true"` here: `//` treats an explicit `false` as empty and
110
- # would return the default, silently defeating the off-switch. Test `!= false`
111
- # so absent/true => enabled, explicit false => disabled. `enabled` is the
112
- # resolved boolean string ("true"/"false").
113
- local enabled; enabled="$(jq -r '.features.publishing != false' "$CONFIG" 2>/dev/null || echo "true")"
114
156
  case "$action" in
115
157
  status)
158
+ local status; status="$(_hosting_status)"
116
159
  if [ "$JSON" = 1 ]; then
117
- jq -n --argjson on "$enabled" '{domain:"hosting", publishing:$on}'
118
- elif [ "$enabled" = "false" ]; then echo "Hosting (egregore.xyz): OFF"
119
- else echo "Hosting (egregore.xyz): ON"; fi ;;
160
+ printf '%s\n' "$status"
161
+ else
162
+ case "$(printf '%s' "$status" | jq -r '.readiness')" in
163
+ disabled) echo "Hosting (egregore.xyz): OFF" ;;
164
+ connected_configured)
165
+ echo "Hosting (egregore.xyz): ACCESS CONFIGURED"
166
+ echo " Connection not checked." ;;
167
+ public_relay)
168
+ echo "Hosting (egregore.xyz): PUBLIC RELAY"
169
+ echo " Anyone with the link can read published artifacts for 7 days. Connection not checked." ;;
170
+ setup_incomplete)
171
+ echo "Hosting (egregore.xyz): SETUP INCOMPLETE"
172
+ echo " Publishing is enabled; connection setup is incomplete."
173
+ echo " Run: $(printf '%s' "$status" | jq -r '.recovery_command')" ;;
174
+ esac
175
+ fi ;;
120
176
  on)
121
177
  _save '.features.publishing = true'
122
- echo "Hosting (egregore.xyz): ON" ;;
178
+ JSON=0 cmd_hosting status ;;
123
179
  off)
124
180
  _save '.features.publishing = false'
125
181
  echo "Hosting (egregore.xyz): OFF — artifacts will no longer upload to egregore.xyz" ;;
@@ -378,10 +434,12 @@ cmd_people() {
378
434
  cmd_dump() {
379
435
  # Always JSON — the launcher calls this once to render the whole settings screen.
380
436
  local people; people="$(_people_json)"
381
- jq --argjson people "$people" '{
437
+ local hosting_status; hosting_status="$(_hosting_status)"
438
+ jq --argjson people "$people" --argjson hosting_status "$hosting_status" '{
382
439
  org: .org_name,
383
440
  slug: .slug,
384
441
  hosting: (.features.publishing != false),
442
+ hosting_status: $hosting_status,
385
443
  public_relay: (.features.public_relay == true),
386
444
  workflow: (
387
445
  if ((if has("base_branch") then .base_branch else "develop" end) == "main") then "simple"
@@ -21,6 +21,26 @@ from .policy import scope_allows_path
21
21
  KINDS = {"bug", "improvement", "question"}
22
22
  AREAS = {"runtime", "onboarding", "connectors", "site", "other"}
23
23
  SEVERITIES = {"critical", "high", "normal"}
24
+ SUPPORT_STATES = {"filed", "failed", "submitting", "uncertain"}
25
+
26
+
27
+ def support_receipt(result, identifier):
28
+ """Accept only a receipt for this request; filing never proves notification."""
29
+ if (not isinstance(result, dict) or result.get('request_id') != identifier
30
+ or not isinstance(result.get('submission_state'), str)
31
+ or result['submission_state'] not in SUPPORT_STATES):
32
+ raise ValueError(f"private support returned an invalid receipt; keep request {identifier} and use report-status")
33
+ state = result['submission_state']
34
+ notices = {
35
+ 'filed': 'Filed with Egregore support. Telegram delivery has not been verified.',
36
+ 'failed': 'Report preserved; support filing failed. Retry this same request.',
37
+ 'submitting': 'Report preserved; support filing is not confirmed. Keep this request ID and check report-status.',
38
+ 'uncertain': 'Report preserved; support filing needs reconciliation. Keep this request ID and check report-status.',
39
+ }
40
+ return {'request_id': identifier, 'submission_state': state,
41
+ 'retryable': state == 'failed',
42
+ 'reconciliation_needed': state in {'submitting', 'uncertain'},
43
+ 'notification_state': 'unverified', 'notice': notices[state]}
24
44
 
25
45
 
26
46
  def _scrub(value, *, public=False):
@@ -207,18 +227,29 @@ class FrameworkReporting:
207
227
  return {'request_id':identifier,'status':result['status'],'github_url':result['github_url']}
208
228
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
209
229
  state['status'] = 'submitting'; GitHubIssues._save(path,state)
210
- code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
211
- {'request_id':identifier, **state['payload']},credential=credential)
212
- if code >= 400:
230
+ try:
231
+ code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
232
+ {'request_id':identifier, **state['payload']},credential=credential)
233
+ except OSError:
234
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
235
+ raise OSError(f"private support response unavailable; keep request {identifier} and use report-status or confirm this same preview") from None
236
+ if not 200 <= code < 300:
213
237
  state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
214
238
  raise ValueError(f"private support request {identifier} needs recovery (HTTP {code}); use report-status or confirm this same preview")
215
- state['status'] = result.get('submission_state','unknown'); GitHubIssues._save(path,state)
216
- return result
239
+ try:
240
+ receipt = support_receipt(result, identifier)
241
+ except ValueError:
242
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
243
+ raise
244
+ state['status'] = receipt['submission_state']; GitHubIssues._save(path,state)
245
+ if state['status'] != 'filed':
246
+ raise ValueError(f"private support request {identifier} needs recovery ({state['status']}); use report-status or confirm this same preview")
247
+ return receipt
217
248
 
218
249
  def status(self, identifier):
219
250
  path, state = self._load('report',identifier,write=False)
220
251
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
221
252
  code, result = self.api(self.root,state['destination'],f'/api/v1/support-reports/{identifier}',credential=credential)
222
- if code >= 400:
253
+ if not 200 <= code < 300:
223
254
  raise ValueError(f"private support status unavailable (HTTP {code}); keep request {identifier}")
224
- return result
255
+ return support_receipt(result, identifier)
@@ -56,7 +56,7 @@ _control_plane_push() {
56
56
  local api_url api_key payload actor response revision
57
57
  api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
58
58
  [ -n "$api_url" ] || return 0
59
- api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2-)
59
+ api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)
60
60
  [ -n "$api_key" ] || return 0
61
61
  actor=$(jq -r '.github_username // empty' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null)
62
62
  payload=$(jq -c --arg by "$actor" '{
@@ -104,22 +104,78 @@ EOF
104
104
 
105
105
  # ── verbs ────────────────────────────────────────────────────────────────
106
106
 
107
+ _hosting_status() {
108
+ # Read local configuration only. A saved key is not proof that the hosted
109
+ # service can be reached or that the credential is still accepted.
110
+ local api_key="" has_key=false slug recovery="egregore connect"
111
+ if [ -r "$SCRIPT_DIR/.env" ]; then
112
+ api_key=$(sed -n 's/^EGREGORE_API_KEY=//p' "$SCRIPT_DIR/.env" | tail -n 1)
113
+ api_key="${api_key#"${api_key%%[![:space:]]*}"}"
114
+ api_key="${api_key%"${api_key##*[![:space:]]}"}"
115
+ case "$api_key" in
116
+ \"*\"|\'*\') api_key="${api_key:1:${#api_key}-2}" ;;
117
+ esac
118
+ [ -n "${api_key//[[:space:]]/}" ] && has_key=true
119
+ fi
120
+ # Match publish-artifact.sh's route selection separately from the saved
121
+ # configuration check. Raw nonempty overrides (even malformed ones) select
122
+ # organisation upload, so they must never imply the public relay's expiry.
123
+ local effective_url="${EGREGORE_API_URL:-}" effective_key="${EGREGORE_API_KEY:-}" org_route=false
124
+ if [ -f "$SCRIPT_DIR/.env" ]; then
125
+ effective_url="${effective_url:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
126
+ effective_key="${effective_key:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
127
+ fi
128
+ effective_url="${effective_url:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
129
+ if [ -n "$effective_url" ] && [ -n "$effective_key" ]; then org_route=true; fi
130
+ slug=$(jq -r '.slug // empty' "$CONFIG")
131
+ if _valid_token "$slug"; then recovery="egregore connect $slug"; fi
132
+ jq --argjson has_key "$has_key" --argjson org_route "$org_route" --arg recovery "$recovery" '
133
+ def nonblank: if type == "string" then test("\\S") else false end;
134
+ (.features.publishing != false) as $publishing |
135
+ (.features.public_relay == true) as $relay |
136
+ (((if .mode == null then "connected" else .mode end) == "connected") and
137
+ (.api_url | nonblank) and $has_key) as $configured |
138
+ (if $publishing == false then "disabled"
139
+ elif $configured then "connected_configured"
140
+ elif $org_route then "setup_incomplete"
141
+ elif $relay then "public_relay"
142
+ else "setup_incomplete" end) as $readiness |
143
+ {
144
+ domain: "hosting",
145
+ publishing: $publishing,
146
+ readiness: $readiness,
147
+ connection_configured: $configured,
148
+ public_relay: $relay,
149
+ connectivity: "not_checked",
150
+ recovery_command: (if $readiness == "setup_incomplete" then $recovery else null end)
151
+ }' "$CONFIG"
152
+ }
153
+
107
154
  cmd_hosting() {
108
155
  local action="${1:-status}"
109
- # NEVER use jq's `// "true"` here: `//` treats an explicit `false` as empty and
110
- # would return the default, silently defeating the off-switch. Test `!= false`
111
- # so absent/true => enabled, explicit false => disabled. `enabled` is the
112
- # resolved boolean string ("true"/"false").
113
- local enabled; enabled="$(jq -r '.features.publishing != false' "$CONFIG" 2>/dev/null || echo "true")"
114
156
  case "$action" in
115
157
  status)
158
+ local status; status="$(_hosting_status)"
116
159
  if [ "$JSON" = 1 ]; then
117
- jq -n --argjson on "$enabled" '{domain:"hosting", publishing:$on}'
118
- elif [ "$enabled" = "false" ]; then echo "Hosting (egregore.xyz): OFF"
119
- else echo "Hosting (egregore.xyz): ON"; fi ;;
160
+ printf '%s\n' "$status"
161
+ else
162
+ case "$(printf '%s' "$status" | jq -r '.readiness')" in
163
+ disabled) echo "Hosting (egregore.xyz): OFF" ;;
164
+ connected_configured)
165
+ echo "Hosting (egregore.xyz): ACCESS CONFIGURED"
166
+ echo " Connection not checked." ;;
167
+ public_relay)
168
+ echo "Hosting (egregore.xyz): PUBLIC RELAY"
169
+ echo " Anyone with the link can read published artifacts for 7 days. Connection not checked." ;;
170
+ setup_incomplete)
171
+ echo "Hosting (egregore.xyz): SETUP INCOMPLETE"
172
+ echo " Publishing is enabled; connection setup is incomplete."
173
+ echo " Run: $(printf '%s' "$status" | jq -r '.recovery_command')" ;;
174
+ esac
175
+ fi ;;
120
176
  on)
121
177
  _save '.features.publishing = true'
122
- echo "Hosting (egregore.xyz): ON" ;;
178
+ JSON=0 cmd_hosting status ;;
123
179
  off)
124
180
  _save '.features.publishing = false'
125
181
  echo "Hosting (egregore.xyz): OFF — artifacts will no longer upload to egregore.xyz" ;;
@@ -378,10 +434,12 @@ cmd_people() {
378
434
  cmd_dump() {
379
435
  # Always JSON — the launcher calls this once to render the whole settings screen.
380
436
  local people; people="$(_people_json)"
381
- jq --argjson people "$people" '{
437
+ local hosting_status; hosting_status="$(_hosting_status)"
438
+ jq --argjson people "$people" --argjson hosting_status "$hosting_status" '{
382
439
  org: .org_name,
383
440
  slug: .slug,
384
441
  hosting: (.features.publishing != false),
442
+ hosting_status: $hosting_status,
385
443
  public_relay: (.features.public_relay == true),
386
444
  workflow: (
387
445
  if ((if has("base_branch") then .base_branch else "develop" end) == "main") then "simple"
@@ -21,6 +21,26 @@ from .policy import scope_allows_path
21
21
  KINDS = {"bug", "improvement", "question"}
22
22
  AREAS = {"runtime", "onboarding", "connectors", "site", "other"}
23
23
  SEVERITIES = {"critical", "high", "normal"}
24
+ SUPPORT_STATES = {"filed", "failed", "submitting", "uncertain"}
25
+
26
+
27
+ def support_receipt(result, identifier):
28
+ """Accept only a receipt for this request; filing never proves notification."""
29
+ if (not isinstance(result, dict) or result.get('request_id') != identifier
30
+ or not isinstance(result.get('submission_state'), str)
31
+ or result['submission_state'] not in SUPPORT_STATES):
32
+ raise ValueError(f"private support returned an invalid receipt; keep request {identifier} and use report-status")
33
+ state = result['submission_state']
34
+ notices = {
35
+ 'filed': 'Filed with Egregore support. Telegram delivery has not been verified.',
36
+ 'failed': 'Report preserved; support filing failed. Retry this same request.',
37
+ 'submitting': 'Report preserved; support filing is not confirmed. Keep this request ID and check report-status.',
38
+ 'uncertain': 'Report preserved; support filing needs reconciliation. Keep this request ID and check report-status.',
39
+ }
40
+ return {'request_id': identifier, 'submission_state': state,
41
+ 'retryable': state == 'failed',
42
+ 'reconciliation_needed': state in {'submitting', 'uncertain'},
43
+ 'notification_state': 'unverified', 'notice': notices[state]}
24
44
 
25
45
 
26
46
  def _scrub(value, *, public=False):
@@ -207,18 +227,29 @@ class FrameworkReporting:
207
227
  return {'request_id':identifier,'status':result['status'],'github_url':result['github_url']}
208
228
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
209
229
  state['status'] = 'submitting'; GitHubIssues._save(path,state)
210
- code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
211
- {'request_id':identifier, **state['payload']},credential=credential)
212
- if code >= 400:
230
+ try:
231
+ code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
232
+ {'request_id':identifier, **state['payload']},credential=credential)
233
+ except OSError:
234
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
235
+ raise OSError(f"private support response unavailable; keep request {identifier} and use report-status or confirm this same preview") from None
236
+ if not 200 <= code < 300:
213
237
  state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
214
238
  raise ValueError(f"private support request {identifier} needs recovery (HTTP {code}); use report-status or confirm this same preview")
215
- state['status'] = result.get('submission_state','unknown'); GitHubIssues._save(path,state)
216
- return result
239
+ try:
240
+ receipt = support_receipt(result, identifier)
241
+ except ValueError:
242
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
243
+ raise
244
+ state['status'] = receipt['submission_state']; GitHubIssues._save(path,state)
245
+ if state['status'] != 'filed':
246
+ raise ValueError(f"private support request {identifier} needs recovery ({state['status']}); use report-status or confirm this same preview")
247
+ return receipt
217
248
 
218
249
  def status(self, identifier):
219
250
  path, state = self._load('report',identifier,write=False)
220
251
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
221
252
  code, result = self.api(self.root,state['destination'],f'/api/v1/support-reports/{identifier}',credential=credential)
222
- if code >= 400:
253
+ if not 200 <= code < 300:
223
254
  raise ValueError(f"private support status unavailable (HTTP {code}); keep request {identifier}")
224
- return result
255
+ return support_receipt(result, identifier)
@@ -56,7 +56,7 @@ _control_plane_push() {
56
56
  local api_url api_key payload actor response revision
57
57
  api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
58
58
  [ -n "$api_url" ] || return 0
59
- api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2-)
59
+ api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)
60
60
  [ -n "$api_key" ] || return 0
61
61
  actor=$(jq -r '.github_username // empty' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null)
62
62
  payload=$(jq -c --arg by "$actor" '{
@@ -104,22 +104,78 @@ EOF
104
104
 
105
105
  # ── verbs ────────────────────────────────────────────────────────────────
106
106
 
107
+ _hosting_status() {
108
+ # Read local configuration only. A saved key is not proof that the hosted
109
+ # service can be reached or that the credential is still accepted.
110
+ local api_key="" has_key=false slug recovery="egregore connect"
111
+ if [ -r "$SCRIPT_DIR/.env" ]; then
112
+ api_key=$(sed -n 's/^EGREGORE_API_KEY=//p' "$SCRIPT_DIR/.env" | tail -n 1)
113
+ api_key="${api_key#"${api_key%%[![:space:]]*}"}"
114
+ api_key="${api_key%"${api_key##*[![:space:]]}"}"
115
+ case "$api_key" in
116
+ \"*\"|\'*\') api_key="${api_key:1:${#api_key}-2}" ;;
117
+ esac
118
+ [ -n "${api_key//[[:space:]]/}" ] && has_key=true
119
+ fi
120
+ # Match publish-artifact.sh's route selection separately from the saved
121
+ # configuration check. Raw nonempty overrides (even malformed ones) select
122
+ # organisation upload, so they must never imply the public relay's expiry.
123
+ local effective_url="${EGREGORE_API_URL:-}" effective_key="${EGREGORE_API_KEY:-}" org_route=false
124
+ if [ -f "$SCRIPT_DIR/.env" ]; then
125
+ effective_url="${effective_url:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
126
+ effective_key="${effective_key:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
127
+ fi
128
+ effective_url="${effective_url:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
129
+ if [ -n "$effective_url" ] && [ -n "$effective_key" ]; then org_route=true; fi
130
+ slug=$(jq -r '.slug // empty' "$CONFIG")
131
+ if _valid_token "$slug"; then recovery="egregore connect $slug"; fi
132
+ jq --argjson has_key "$has_key" --argjson org_route "$org_route" --arg recovery "$recovery" '
133
+ def nonblank: if type == "string" then test("\\S") else false end;
134
+ (.features.publishing != false) as $publishing |
135
+ (.features.public_relay == true) as $relay |
136
+ (((if .mode == null then "connected" else .mode end) == "connected") and
137
+ (.api_url | nonblank) and $has_key) as $configured |
138
+ (if $publishing == false then "disabled"
139
+ elif $configured then "connected_configured"
140
+ elif $org_route then "setup_incomplete"
141
+ elif $relay then "public_relay"
142
+ else "setup_incomplete" end) as $readiness |
143
+ {
144
+ domain: "hosting",
145
+ publishing: $publishing,
146
+ readiness: $readiness,
147
+ connection_configured: $configured,
148
+ public_relay: $relay,
149
+ connectivity: "not_checked",
150
+ recovery_command: (if $readiness == "setup_incomplete" then $recovery else null end)
151
+ }' "$CONFIG"
152
+ }
153
+
107
154
  cmd_hosting() {
108
155
  local action="${1:-status}"
109
- # NEVER use jq's `// "true"` here: `//` treats an explicit `false` as empty and
110
- # would return the default, silently defeating the off-switch. Test `!= false`
111
- # so absent/true => enabled, explicit false => disabled. `enabled` is the
112
- # resolved boolean string ("true"/"false").
113
- local enabled; enabled="$(jq -r '.features.publishing != false' "$CONFIG" 2>/dev/null || echo "true")"
114
156
  case "$action" in
115
157
  status)
158
+ local status; status="$(_hosting_status)"
116
159
  if [ "$JSON" = 1 ]; then
117
- jq -n --argjson on "$enabled" '{domain:"hosting", publishing:$on}'
118
- elif [ "$enabled" = "false" ]; then echo "Hosting (egregore.xyz): OFF"
119
- else echo "Hosting (egregore.xyz): ON"; fi ;;
160
+ printf '%s\n' "$status"
161
+ else
162
+ case "$(printf '%s' "$status" | jq -r '.readiness')" in
163
+ disabled) echo "Hosting (egregore.xyz): OFF" ;;
164
+ connected_configured)
165
+ echo "Hosting (egregore.xyz): ACCESS CONFIGURED"
166
+ echo " Connection not checked." ;;
167
+ public_relay)
168
+ echo "Hosting (egregore.xyz): PUBLIC RELAY"
169
+ echo " Anyone with the link can read published artifacts for 7 days. Connection not checked." ;;
170
+ setup_incomplete)
171
+ echo "Hosting (egregore.xyz): SETUP INCOMPLETE"
172
+ echo " Publishing is enabled; connection setup is incomplete."
173
+ echo " Run: $(printf '%s' "$status" | jq -r '.recovery_command')" ;;
174
+ esac
175
+ fi ;;
120
176
  on)
121
177
  _save '.features.publishing = true'
122
- echo "Hosting (egregore.xyz): ON" ;;
178
+ JSON=0 cmd_hosting status ;;
123
179
  off)
124
180
  _save '.features.publishing = false'
125
181
  echo "Hosting (egregore.xyz): OFF — artifacts will no longer upload to egregore.xyz" ;;
@@ -378,10 +434,12 @@ cmd_people() {
378
434
  cmd_dump() {
379
435
  # Always JSON — the launcher calls this once to render the whole settings screen.
380
436
  local people; people="$(_people_json)"
381
- jq --argjson people "$people" '{
437
+ local hosting_status; hosting_status="$(_hosting_status)"
438
+ jq --argjson people "$people" --argjson hosting_status "$hosting_status" '{
382
439
  org: .org_name,
383
440
  slug: .slug,
384
441
  hosting: (.features.publishing != false),
442
+ hosting_status: $hosting_status,
385
443
  public_relay: (.features.public_relay == true),
386
444
  workflow: (
387
445
  if ((if has("base_branch") then .base_branch else "develop" end) == "main") then "simple"
@@ -21,6 +21,26 @@ from .policy import scope_allows_path
21
21
  KINDS = {"bug", "improvement", "question"}
22
22
  AREAS = {"runtime", "onboarding", "connectors", "site", "other"}
23
23
  SEVERITIES = {"critical", "high", "normal"}
24
+ SUPPORT_STATES = {"filed", "failed", "submitting", "uncertain"}
25
+
26
+
27
+ def support_receipt(result, identifier):
28
+ """Accept only a receipt for this request; filing never proves notification."""
29
+ if (not isinstance(result, dict) or result.get('request_id') != identifier
30
+ or not isinstance(result.get('submission_state'), str)
31
+ or result['submission_state'] not in SUPPORT_STATES):
32
+ raise ValueError(f"private support returned an invalid receipt; keep request {identifier} and use report-status")
33
+ state = result['submission_state']
34
+ notices = {
35
+ 'filed': 'Filed with Egregore support. Telegram delivery has not been verified.',
36
+ 'failed': 'Report preserved; support filing failed. Retry this same request.',
37
+ 'submitting': 'Report preserved; support filing is not confirmed. Keep this request ID and check report-status.',
38
+ 'uncertain': 'Report preserved; support filing needs reconciliation. Keep this request ID and check report-status.',
39
+ }
40
+ return {'request_id': identifier, 'submission_state': state,
41
+ 'retryable': state == 'failed',
42
+ 'reconciliation_needed': state in {'submitting', 'uncertain'},
43
+ 'notification_state': 'unverified', 'notice': notices[state]}
24
44
 
25
45
 
26
46
  def _scrub(value, *, public=False):
@@ -207,18 +227,29 @@ class FrameworkReporting:
207
227
  return {'request_id':identifier,'status':result['status'],'github_url':result['github_url']}
208
228
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
209
229
  state['status'] = 'submitting'; GitHubIssues._save(path,state)
210
- code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
211
- {'request_id':identifier, **state['payload']},credential=credential)
212
- if code >= 400:
230
+ try:
231
+ code, result = self.api(self.root,state['destination'],'/api/v1/support-reports',
232
+ {'request_id':identifier, **state['payload']},credential=credential)
233
+ except OSError:
234
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
235
+ raise OSError(f"private support response unavailable; keep request {identifier} and use report-status or confirm this same preview") from None
236
+ if not 200 <= code < 300:
213
237
  state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
214
238
  raise ValueError(f"private support request {identifier} needs recovery (HTTP {code}); use report-status or confirm this same preview")
215
- state['status'] = result.get('submission_state','unknown'); GitHubIssues._save(path,state)
216
- return result
239
+ try:
240
+ receipt = support_receipt(result, identifier)
241
+ except ValueError:
242
+ state['status'] = 'reconciliation_needed'; GitHubIssues._save(path,state)
243
+ raise
244
+ state['status'] = receipt['submission_state']; GitHubIssues._save(path,state)
245
+ if state['status'] != 'filed':
246
+ raise ValueError(f"private support request {identifier} needs recovery ({state['status']}); use report-status or confirm this same preview")
247
+ return receipt
217
248
 
218
249
  def status(self, identifier):
219
250
  path, state = self._load('report',identifier,write=False)
220
251
  credential, _ = self._verified_credential(state['destination'],state['authenticated_org'])
221
252
  code, result = self.api(self.root,state['destination'],f'/api/v1/support-reports/{identifier}',credential=credential)
222
- if code >= 400:
253
+ if not 200 <= code < 300:
223
254
  raise ValueError(f"private support status unavailable (HTTP {code}); keep request {identifier}")
224
- return result
255
+ return support_receipt(result, identifier)