@elmundi/ship-cli 0.8.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +415 -22
  2. package/bin/shipctl.mjs +165 -0
  3. package/lib/adapters/_fs.mjs +165 -0
  4. package/lib/adapters/agents/index.mjs +26 -0
  5. package/lib/adapters/ci/azure-pipelines.mjs +23 -0
  6. package/lib/adapters/ci/buildkite.mjs +24 -0
  7. package/lib/adapters/ci/circleci.mjs +23 -0
  8. package/lib/adapters/ci/gh-actions.mjs +29 -0
  9. package/lib/adapters/ci/gitlab-ci.mjs +23 -0
  10. package/lib/adapters/ci/jenkins.mjs +23 -0
  11. package/lib/adapters/ci/manual.mjs +18 -0
  12. package/lib/adapters/index.mjs +122 -0
  13. package/lib/adapters/language/dart.mjs +23 -0
  14. package/lib/adapters/language/go.mjs +23 -0
  15. package/lib/adapters/language/java.mjs +27 -0
  16. package/lib/adapters/language/js.mjs +32 -0
  17. package/lib/adapters/language/kotlin.mjs +48 -0
  18. package/lib/adapters/language/py.mjs +34 -0
  19. package/lib/adapters/language/rust.mjs +23 -0
  20. package/lib/adapters/language/swift.mjs +37 -0
  21. package/lib/adapters/language/ts.mjs +35 -0
  22. package/lib/adapters/trackers/azure-boards.mjs +49 -0
  23. package/lib/adapters/trackers/clickup.mjs +43 -0
  24. package/lib/adapters/trackers/github-issues.mjs +52 -0
  25. package/lib/adapters/trackers/jira.mjs +72 -0
  26. package/lib/adapters/trackers/linear.mjs +62 -0
  27. package/lib/adapters/trackers/none.mjs +18 -0
  28. package/lib/adapters/trackers/spreadsheet.mjs +28 -0
  29. package/lib/artifacts/fs-index.mjs +230 -0
  30. package/lib/bootstrap/render.mjs +373 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +302 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +1 -1
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +96 -21
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +368 -0
  42. package/lib/commands/lanes.mjs +502 -0
  43. package/lib/commands/manifest-catalog.mjs +102 -38
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +9 -43
  47. package/lib/commands/run.mjs +617 -0
  48. package/lib/commands/sync.mjs +749 -0
  49. package/lib/commands/telemetry.mjs +390 -0
  50. package/lib/commands/verify.mjs +187 -0
  51. package/lib/config/io.mjs +232 -0
  52. package/lib/config/migrate.mjs +215 -0
  53. package/lib/config/schema.mjs +650 -0
  54. package/lib/detect.mjs +162 -19
  55. package/lib/feedback/drafts.mjs +129 -0
  56. package/lib/find-ship-root.mjs +16 -10
  57. package/lib/http.mjs +237 -11
  58. package/lib/state/idempotency.mjs +183 -0
  59. package/lib/state/lockfile.mjs +180 -0
  60. package/lib/telemetry/outbox.mjs +224 -0
  61. package/lib/templates.mjs +53 -65
  62. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  63. package/lib/verify/checks/api-reachable.mjs +39 -0
  64. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  65. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  66. package/lib/verify/checks/cache-integrity.mjs +51 -0
  67. package/lib/verify/checks/ci-secrets.mjs +86 -0
  68. package/lib/verify/checks/config-present.mjs +39 -0
  69. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  70. package/lib/verify/checks/rules-markers.mjs +135 -0
  71. package/lib/verify/checks/stack-enums.mjs +33 -0
  72. package/lib/verify/checks/tracker-labels.mjs +91 -0
  73. package/lib/verify/registry.mjs +120 -0
  74. package/lib/version.mjs +34 -0
  75. package/package.json +10 -3
  76. package/bin/ship.mjs +0 -68
package/lib/detect.mjs CHANGED
@@ -2,58 +2,201 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  /**
5
+ * @typedef {Object} AgentTarget
6
+ * @property {string} id
7
+ * @property {string} label
8
+ * @property {string[]} paths
9
+ * @property {number} confidence 0..1 — 1 for a definitive marker, 0.5 for directory-only.
10
+ */
11
+
12
+ /**
13
+ * Detect agent integration targets in a repository.
14
+ *
15
+ * Each detector looks for marker files or directories that uniquely identify a
16
+ * given agent configuration (per RFC-0004 "Detect (signals)"). Returned list is
17
+ * sorted by `confidence` descending so callers (init, doctor, stack emit) can
18
+ * pick the strongest signals first.
19
+ *
5
20
  * @param {string} cwd
6
- * @returns {{ id: string; label: string; paths: string[] }[]}
21
+ * @returns {AgentTarget[]}
7
22
  */
8
23
  export function detectAgentTargets(cwd) {
9
- /** @type {{ id: string; label: string; paths: string[] }[]} */
24
+ const exists = (...p) => fs.existsSync(path.join(cwd, ...p));
25
+ const abs = (...p) => path.join(cwd, ...p);
26
+
27
+ /** @type {AgentTarget[]} */
10
28
  const targets = [];
11
29
 
12
- const cursorDir = path.join(cwd, ".cursor");
13
- const cursorRules = path.join(cursorDir, "rules");
14
- if (fs.existsSync(cursorDir)) {
30
+ if (exists(".cursor")) {
15
31
  targets.push({
16
32
  id: "cursor",
17
33
  label: "Cursor (`.cursor/` present)",
18
- paths: [path.join(cursorRules, "ship-methodology-api.mdc")],
34
+ paths: [abs(".cursor", "rules", "ship-artifacts-protocol.mdc")],
35
+ confidence: exists(".cursor", "rules") ? 1 : 0.8,
19
36
  });
20
37
  }
21
38
 
22
- const agents = path.join(cwd, "AGENTS.md");
23
- if (fs.existsSync(agents)) {
39
+ if (exists("AGENTS.md")) {
24
40
  targets.push({
25
41
  id: "agents-md",
26
42
  label: "OpenAI Codex / generic `AGENTS.md`",
27
- paths: [agents],
43
+ paths: [abs("AGENTS.md")],
44
+ confidence: 1,
28
45
  });
29
46
  }
30
47
 
31
- const claude = path.join(cwd, "CLAUDE.md");
32
- if (fs.existsSync(claude)) {
48
+ if (exists("CLAUDE.md")) {
33
49
  targets.push({
34
50
  id: "claude-md",
35
51
  label: "Claude Code `CLAUDE.md`",
36
- paths: [claude],
52
+ paths: [abs("CLAUDE.md")],
53
+ confidence: 1,
37
54
  });
38
55
  }
39
56
 
40
- const codexDir = path.join(cwd, ".codex");
41
- if (fs.existsSync(codexDir)) {
57
+ if (exists(".codex")) {
42
58
  targets.push({
43
59
  id: "codex",
44
60
  label: "Codex config dir (`.codex/`)",
45
- paths: [path.join(codexDir, "SHIP_API.md")],
61
+ paths: [abs(".codex", "SHIP_API.md")],
62
+ confidence: 0.8,
46
63
  });
47
64
  }
48
65
 
49
- const copilot = path.join(cwd, ".github", "copilot-instructions.md");
50
- if (fs.existsSync(copilot)) {
66
+ if (exists(".github", "copilot-instructions.md")) {
51
67
  targets.push({
52
68
  id: "copilot",
53
69
  label: "GitHub Copilot instructions",
54
- paths: [copilot],
70
+ paths: [abs(".github", "copilot-instructions.md")],
71
+ confidence: 1,
72
+ });
73
+ }
74
+
75
+ const aiderSignals = [".aider.conf.yml", "AIDER.md", ".aider"];
76
+ const aiderHit = aiderSignals.find((p) => exists(p));
77
+ if (aiderHit) {
78
+ targets.push({
79
+ id: "aider",
80
+ label: "Aider (`.aider.conf.yml` present)",
81
+ paths: [abs("AIDER.md")],
82
+ confidence: aiderHit === ".aider" ? 0.5 : 1,
83
+ });
84
+ }
85
+
86
+ const clineSignals = [".clinerules", ".rooignore"];
87
+ const clineHit = clineSignals.find((p) => exists(p));
88
+ if (clineHit) {
89
+ targets.push({
90
+ id: "cline",
91
+ label: "Cline/Roo (`.clinerules`)",
92
+ paths: [abs(".clinerules")],
93
+ confidence: 1,
94
+ });
95
+ }
96
+
97
+ const continueSignals = [
98
+ path.join(".continue", "config.json"),
99
+ path.join(".continue", "config.yaml"),
100
+ ".continue",
101
+ ];
102
+ const continueHit = continueSignals.find((p) => exists(p));
103
+ if (continueHit) {
104
+ targets.push({
105
+ id: "continue",
106
+ label: "Continue.dev (`.continue/`)",
107
+ paths: [abs(".continue", "ship.md")],
108
+ confidence: continueHit === ".continue" ? 0.5 : 1,
55
109
  });
56
110
  }
57
111
 
58
- return targets;
112
+ if (exists(".windsurfrules")) {
113
+ targets.push({
114
+ id: "windsurf",
115
+ label: "Windsurf (`.windsurfrules`)",
116
+ paths: [abs(".windsurfrules")],
117
+ confidence: 1,
118
+ });
119
+ }
120
+
121
+ const zedSignals = [path.join(".zed", "settings.json"), ".zed"];
122
+ const zedHit = zedSignals.find((p) => exists(p));
123
+ if (zedHit) {
124
+ targets.push({
125
+ id: "zed",
126
+ label: "Zed AI (`.zed/`)",
127
+ paths: [abs(".zed", "ship.md")],
128
+ confidence: zedHit === ".zed" ? 0.5 : 1,
129
+ });
130
+ }
131
+
132
+ const geminiSignals = ["GEMINI.md", ".gemini"];
133
+ const geminiHit = geminiSignals.find((p) => exists(p));
134
+ if (geminiHit) {
135
+ targets.push({
136
+ id: "gemini",
137
+ label: "Gemini CLI (`GEMINI.md`)",
138
+ paths: [abs("GEMINI.md")],
139
+ confidence: geminiHit === ".gemini" ? 0.5 : 1,
140
+ });
141
+ }
142
+
143
+ if (exists(".opencode")) {
144
+ targets.push({
145
+ id: "opencode",
146
+ label: "OpenCode (`.opencode/`)",
147
+ paths: [abs(".opencode", "ship.md")],
148
+ confidence: 0.5,
149
+ });
150
+ }
151
+
152
+ if (exists(".cursor", "environments.json")) {
153
+ targets.push({
154
+ id: "cursor-cloud",
155
+ label: "Cursor Cloud Agent env",
156
+ paths: [abs(".cursor", "environments.json")],
157
+ confidence: 1,
158
+ });
159
+ }
160
+
161
+ return targets.sort((a, b) => b.confidence - a.confidence);
162
+ }
163
+
164
+ /**
165
+ * Fixed catalog of agent ids + default paths used when the user forces
166
+ * an agent via `--agents` and the marker is missing.
167
+ * Keeping this in sync with detectAgentTargets() is intentional: the
168
+ * "target file if missing" column in RFC-0004 maps here.
169
+ */
170
+ /**
171
+ * Map a raw on-disk agent signal id to the preferred agent id after taking
172
+ * the declared `.ship/config.yml` agents into account. Today this only
173
+ * re-maps `agents-md` → `codex` when config lists `codex`, so doctor and
174
+ * verify can reconcile a Codex-configured repo that only has `AGENTS.md`
175
+ * on disk (the codex rules-render target emits `AGENTS.md`, not
176
+ * `.codex/SHIP_API.md`).
177
+ *
178
+ * @param {string} signalId raw detector id (e.g. "agents-md")
179
+ * @param {string[]=} configuredAgents agents listed in `.ship/config.yml`
180
+ * @returns {string} preferred agent id after reconciliation
181
+ */
182
+ export function resolveAgentSignal(signalId, configuredAgents) {
183
+ const configured = Array.isArray(configuredAgents) ? configuredAgents : [];
184
+ if (signalId === "agents-md" && configured.includes("codex")) return "codex";
185
+ return signalId;
59
186
  }
187
+
188
+ export const KNOWN_AGENTS = Object.freeze({
189
+ cursor: { label: "Cursor", targetRel: [".cursor", "rules", "ship-artifacts-protocol.mdc"] },
190
+ "agents-md": { label: "AGENTS.md (Codex/generic)", targetRel: ["AGENTS.md"] },
191
+ "claude-md": { label: "Claude Code CLAUDE.md", targetRel: ["CLAUDE.md"] },
192
+ codex: { label: "Codex (.codex/)", targetRel: [".codex", "SHIP_API.md"] },
193
+ copilot: { label: "GitHub Copilot", targetRel: [".github", "copilot-instructions.md"] },
194
+ aider: { label: "Aider", targetRel: ["AIDER.md"] },
195
+ cline: { label: "Cline/Roo", targetRel: [".clinerules"] },
196
+ continue: { label: "Continue.dev", targetRel: [".continue", "ship.md"] },
197
+ windsurf: { label: "Windsurf", targetRel: [".windsurfrules"] },
198
+ zed: { label: "Zed", targetRel: [".zed", "ship.md"] },
199
+ gemini: { label: "Gemini CLI", targetRel: ["GEMINI.md"] },
200
+ opencode: { label: "OpenCode", targetRel: [".opencode", "ship.md"] },
201
+ "cursor-cloud": { label: "Cursor Cloud Agent", targetRel: [".cursor", "environments.json"] },
202
+ });
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+
5
+ export function draftsDir(shipRoot) {
6
+ return path.join(shipRoot, ".ship", "feedback-drafts");
7
+ }
8
+
9
+ function safeSlug(s) {
10
+ return String(s || "draft")
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9._-]+/g, "-")
13
+ .replace(/^-+|-+$/g, "")
14
+ .slice(0, 64) || "draft";
15
+ }
16
+
17
+ function timestamp() {
18
+ const iso = new Date().toISOString();
19
+ return iso.replace(/[:T]/g, "-").replace(/\..+$/, "");
20
+ }
21
+
22
+ /**
23
+ * Create a new feedback draft on disk.
24
+ *
25
+ * @param {string} shipRoot
26
+ * @param {{
27
+ * kind: string, id: string, version?: string,
28
+ * title: string, summary: string,
29
+ * recommendation?: string, stack?: object, tags?: string[]
30
+ * }} fields
31
+ * @returns {string} the absolute path of the draft file
32
+ */
33
+ export function createDraft(shipRoot, fields) {
34
+ const kind = String(fields.kind || "").trim();
35
+ const id = String(fields.id || "").trim();
36
+ if (!kind || !id) {
37
+ throw new Error("createDraft: kind and id are required");
38
+ }
39
+
40
+ const dir = draftsDir(shipRoot);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+
43
+ const name = `${timestamp()}-${safeSlug(kind)}-${safeSlug(id)}.md`;
44
+ const fp = path.join(dir, name);
45
+
46
+ const meta = {
47
+ kind,
48
+ id,
49
+ version: fields.version || null,
50
+ tags: Array.isArray(fields.tags) ? fields.tags : [],
51
+ created_at: new Date().toISOString(),
52
+ };
53
+ if (fields.title) meta.title = fields.title;
54
+
55
+ const stack = fields.stack || {};
56
+ const stackLine = `tracker=${stack.tracker || "-"}, ci=${stack.ci || "-"}, agents=${
57
+ Array.isArray(stack.agents) ? stack.agents.join("+") || "-" : stack.agents || "-"
58
+ }, preset=${stack.preset || "-"}`;
59
+
60
+ const body =
61
+ `# ${fields.title || ""}\n\n` +
62
+ `**Summary**: ${fields.summary || ""}\n\n` +
63
+ `**Recommendation**: ${fields.recommendation || ""}\n\n` +
64
+ `**Stack context**: ${stackLine}\n\n` +
65
+ `<!-- ship-feedback: v1 -->\n`;
66
+
67
+ const front = YAML.stringify(meta, { lineWidth: 0 });
68
+ const text = `---\n${front}---\n\n${body}`;
69
+ fs.writeFileSync(fp, text, "utf8");
70
+ return fp;
71
+ }
72
+
73
+ /**
74
+ * List all draft files under `.ship/feedback-drafts/`, including under `sent/`.
75
+ * Returns absolute paths, sorted ascending by filename (timestamp-prefixed).
76
+ */
77
+ export function listDrafts(shipRoot) {
78
+ const dir = draftsDir(shipRoot);
79
+ if (!fs.existsSync(dir)) return [];
80
+ const out = [];
81
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
82
+ if (entry.isFile() && entry.name.endsWith(".md")) {
83
+ out.push(path.join(dir, entry.name));
84
+ }
85
+ }
86
+ const sentDir = path.join(dir, "sent");
87
+ if (fs.existsSync(sentDir)) {
88
+ for (const entry of fs.readdirSync(sentDir, { withFileTypes: true })) {
89
+ if (entry.isFile() && entry.name.endsWith(".md")) {
90
+ out.push(path.join(sentDir, entry.name));
91
+ }
92
+ }
93
+ }
94
+ out.sort();
95
+ return out;
96
+ }
97
+
98
+ export function readDraft(filePath) {
99
+ const raw = fs.readFileSync(filePath, "utf8");
100
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
101
+ if (!match) {
102
+ return { meta: {}, body: raw };
103
+ }
104
+ let meta = {};
105
+ try {
106
+ meta = YAML.parse(match[1]) || {};
107
+ } catch (e) {
108
+ throw new Error(`feedback draft: failed to parse front-matter in ${filePath}: ${e.message}`);
109
+ }
110
+ const body = (match[2] || "").replace(/^\n+/, "");
111
+ return { meta, body };
112
+ }
113
+
114
+ export function removeDraft(filePath) {
115
+ fs.unlinkSync(filePath);
116
+ }
117
+
118
+ /**
119
+ * Move a draft into `.ship/feedback-drafts/sent/` preserving history.
120
+ * Returns the new path.
121
+ */
122
+ export function moveDraftToSent(shipRoot, filePath) {
123
+ const dir = path.join(draftsDir(shipRoot), "sent");
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ const base = path.basename(filePath);
126
+ const dest = path.join(dir, base);
127
+ fs.renameSync(filePath, dest);
128
+ return dest;
129
+ }
@@ -1,19 +1,25 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- const MARKERS = [
5
- "workflows/manifest.json",
6
- "tools/manifest.json",
7
- "collections/manifest.json",
8
- "patterns/manifest.json",
4
+ const MARKER_DIRS = [
5
+ "artifacts/patterns",
6
+ "artifacts/tools",
7
+ "artifacts/collections",
9
8
  ];
10
9
 
11
10
  function markersOk(dir) {
12
- return MARKERS.every((m) => fs.existsSync(path.join(dir, m)));
11
+ return MARKER_DIRS.every((rel) => {
12
+ const abs = path.join(dir, rel);
13
+ try {
14
+ return fs.statSync(abs).isDirectory();
15
+ } catch {
16
+ return false;
17
+ }
18
+ });
13
19
  }
14
20
 
15
21
  /**
16
- * Walk parents from cwd for a directory containing all Ship manifest markers.
22
+ * Walk parents from cwd for a directory containing the v2 artifacts/ tree.
17
23
  * @returns {string | null}
18
24
  */
19
25
  export function tryFindShipRepoRootFromWalk() {
@@ -28,7 +34,7 @@ export function tryFindShipRepoRootFromWalk() {
28
34
  }
29
35
 
30
36
  /**
31
- * Root of the Ship monorepo (manifests at repo root).
37
+ * Root of the Ship monorepo (artifacts/<plural>/<id>/ARTIFACT.md present).
32
38
  * Set `SHIP_REPO` to an absolute path when not running from inside the tree.
33
39
  */
34
40
  export function findShipRepoRoot() {
@@ -37,7 +43,7 @@ export function findShipRepoRoot() {
37
43
  const r = path.resolve(env);
38
44
  if (!markersOk(r)) {
39
45
  throw new Error(
40
- `SHIP_REPO=${r} is not the Ship monorepo (expected tools/, workflows/, collections/, patterns/ manifests at repo root).`,
46
+ `SHIP_REPO=${r} is not the Ship monorepo (expected artifacts/{patterns,tools,collections}/ at repo root).`,
41
47
  );
42
48
  }
43
49
  return r;
@@ -60,7 +66,7 @@ export function resolveShipRepoRootForCatalog() {
60
66
  const r = path.resolve(env);
61
67
  if (!markersOk(r)) {
62
68
  throw new Error(
63
- `SHIP_REPO=${r} is not the Ship monorepo (expected tools/, workflows/, collections/, patterns/ manifests at repo root).`,
69
+ `SHIP_REPO=${r} is not the Ship monorepo (expected artifacts/{patterns,tools,collections}/ at repo root).`,
64
70
  );
65
71
  }
66
72
  return r;
package/lib/http.mjs CHANGED
@@ -1,13 +1,50 @@
1
+ import crypto from "node:crypto";
2
+ import { getUserAgent } from "./version.mjs";
3
+
4
+ function joinUrl(baseUrl, path) {
5
+ return `${baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
6
+ }
7
+
8
+ function authHeaders() {
9
+ const token = process.env.SHIP_API_TOKEN;
10
+ return token ? { Authorization: `Bearer ${token}` } : {};
11
+ }
12
+
13
+ /* Stamp a real User-Agent on every outbound call so the methodology API can
14
+ * correlate adoption metrics with the CLI release. The version string lives
15
+ * in cli/package.json (kept in sync with the root VERSION file). */
16
+ function commonHeaders() {
17
+ return {
18
+ "User-Agent": getUserAgent(),
19
+ ...authHeaders(),
20
+ };
21
+ }
22
+
23
+ export class HttpError extends Error {
24
+ constructor(status, statusText, url, body) {
25
+ const msg = typeof body === "string" ? body : JSON.stringify(body);
26
+ super(`HTTP ${status} ${statusText} for ${url}\n${msg}`);
27
+ this.status = status;
28
+ this.statusText = statusText;
29
+ this.url = url;
30
+ this.body = body;
31
+ }
32
+ }
33
+
1
34
  /**
2
35
  * @param {string} baseUrl
3
36
  * @param {string} path
4
37
  * @param {Record<string, unknown>} body
5
38
  */
6
39
  export async function apiPost(baseUrl, path, body) {
7
- const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
40
+ const url = joinUrl(baseUrl, path);
8
41
  const res = await fetch(url, {
9
42
  method: "POST",
10
- headers: { "Content-Type": "application/json", Accept: "application/json" },
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ Accept: "application/json",
46
+ ...commonHeaders(),
47
+ },
11
48
  body: JSON.stringify(body),
12
49
  });
13
50
  const text = await res.text();
@@ -17,10 +54,7 @@ export async function apiPost(baseUrl, path, body) {
17
54
  } catch {
18
55
  data = text;
19
56
  }
20
- if (!res.ok) {
21
- const msg = typeof data === "string" ? data : JSON.stringify(data);
22
- throw new Error(`HTTP ${res.status} ${res.statusText} for POST ${url}\n${msg}`);
23
- }
57
+ if (!res.ok) throw new HttpError(res.status, res.statusText, url, data ?? text);
24
58
  return data;
25
59
  }
26
60
 
@@ -29,8 +63,10 @@ export async function apiPost(baseUrl, path, body) {
29
63
  * @param {string} path
30
64
  */
31
65
  export async function apiGet(baseUrl, path) {
32
- const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
33
- const res = await fetch(url, { headers: { Accept: "application/json" } });
66
+ const url = joinUrl(baseUrl, path);
67
+ const res = await fetch(url, {
68
+ headers: { Accept: "application/json", ...commonHeaders() },
69
+ });
34
70
  const text = await res.text();
35
71
  let data;
36
72
  try {
@@ -38,9 +74,199 @@ export async function apiGet(baseUrl, path) {
38
74
  } catch {
39
75
  data = text;
40
76
  }
41
- if (!res.ok) {
42
- const msg = typeof data === "string" ? data : JSON.stringify(data);
43
- throw new Error(`HTTP ${res.status} ${res.statusText} for GET ${url}\n${msg}`);
77
+ if (!res.ok) throw new HttpError(res.status, res.statusText, url, data ?? text);
78
+ return data;
79
+ }
80
+
81
+ /**
82
+ * Aggregated catalog. RFC-0005 removed the legacy `/manifest` endpoint
83
+ * and RFC-0007 Phase 6 retired the ``artifact_kind=workflow`` layer;
84
+ * the catalog is now exposed as three per-kind routes (`/patterns`,
85
+ * `/tools`, `/collections`). This helper fans them out in parallel and
86
+ * stamps a `kind` field on each entry so callers (sync, verify) keep
87
+ * their existing single-list shape. `channel` is applied client-side
88
+ * because the per-kind endpoints don't filter today.
89
+ *
90
+ * @param {string} baseUrl
91
+ * @param {{channel?:string}} [opts]
92
+ * @returns {Promise<Array<object>>}
93
+ */
94
+ export async function fetchManifest(baseUrl, { channel } = {}) {
95
+ const KINDS = [
96
+ { plural: "patterns", singular: "pattern" },
97
+ { plural: "tools", singular: "tool" },
98
+ { plural: "collections", singular: "collection" },
99
+ ];
100
+ const responses = await Promise.all(
101
+ KINDS.map((k) => apiGet(baseUrl, `/${k.plural}`)),
102
+ );
103
+ /** @type {Array<object>} */
104
+ const entries = [];
105
+ for (let i = 0; i < KINDS.length; i += 1) {
106
+ const { plural, singular } = KINDS[i];
107
+ const data = responses[i];
108
+ const arr = data && Array.isArray(data[plural]) ? data[plural] : [];
109
+ for (const e of arr) {
110
+ entries.push({ ...e, kind: e.kind || singular });
111
+ }
112
+ }
113
+ const wantChannel = (channel || "").toLowerCase();
114
+ if (wantChannel && wantChannel !== "edge") {
115
+ return entries.filter(
116
+ (e) => (e.channel || "stable").toLowerCase() === wantChannel,
117
+ );
118
+ }
119
+ return entries;
120
+ }
121
+
122
+ function sha256Hex(buf) {
123
+ return crypto.createHash("sha256").update(buf).digest("hex");
124
+ }
125
+
126
+ /**
127
+ * POST /fetch with {kind,id,version?}. Handles 404 (version_not_found) and 410 (yanked).
128
+ * Returns {content, meta} where meta is the manifest record + source_url.
129
+ * @param {string} baseUrl
130
+ * @param {string} kind
131
+ * @param {string} id
132
+ * @param {string} [version]
133
+ */
134
+ export async function fetchArtifact(baseUrl, kind, id, version) {
135
+ const body = { kind, id };
136
+ if (version) body.version = version;
137
+ let data;
138
+ try {
139
+ data = await apiPost(baseUrl, "/fetch", body);
140
+ } catch (e) {
141
+ if (e instanceof HttpError && e.status === 404) {
142
+ const err = new Error(
143
+ `artifact not found: ${kind}:${id}${version ? `@${version}` : ""}`,
144
+ );
145
+ err.code = "ARTIFACT_NOT_FOUND";
146
+ throw err;
147
+ }
148
+ if (e instanceof HttpError && e.status === 410) {
149
+ const err = new Error(
150
+ `artifact yanked: ${kind}:${id}${version ? `@${version}` : ""}`,
151
+ );
152
+ err.code = "ARTIFACT_YANKED";
153
+ throw err;
154
+ }
155
+ throw e;
156
+ }
157
+ if (!data || typeof data.content !== "string") {
158
+ throw new Error(`POST /fetch: missing 'content' in response for ${kind}:${id}`);
159
+ }
160
+ const content = data.content;
161
+ const meta = {
162
+ kind: data.kind || kind,
163
+ id: data.id || id,
164
+ version: data.version || version || null,
165
+ content_sha256: data.content_sha256 || sha256Hex(Buffer.from(content, "utf8")),
166
+ updated_at: data.updated_at || null,
167
+ channel: data.channel || null,
168
+ source_url: `${baseUrl.replace(/\/$/, "")}/fetch`,
169
+ };
170
+ return { content, meta };
171
+ }
172
+
173
+ /**
174
+ * POST /fetch with {path, version?} — for raw doc paths.
175
+ * @param {string} baseUrl
176
+ * @param {string} repoRelativePath
177
+ * @param {string} [version]
178
+ */
179
+ export async function fetchDoc(baseUrl, repoRelativePath, version) {
180
+ const body = { path: repoRelativePath };
181
+ if (version) body.version = version;
182
+ let data;
183
+ try {
184
+ data = await apiPost(baseUrl, "/fetch", body);
185
+ } catch (e) {
186
+ if (e instanceof HttpError && e.status === 404) {
187
+ const err = new Error(`doc not found: ${repoRelativePath}`);
188
+ err.code = "ARTIFACT_NOT_FOUND";
189
+ throw err;
190
+ }
191
+ if (e instanceof HttpError && e.status === 410) {
192
+ const err = new Error(`doc yanked: ${repoRelativePath}`);
193
+ err.code = "ARTIFACT_YANKED";
194
+ throw err;
195
+ }
196
+ throw e;
197
+ }
198
+ if (!data || typeof data.content !== "string") {
199
+ throw new Error(`POST /fetch: missing 'content' in response for ${repoRelativePath}`);
200
+ }
201
+ const content = data.content;
202
+ const meta = {
203
+ kind: "doc",
204
+ id: data.id || repoRelativePath,
205
+ path: repoRelativePath,
206
+ version: data.version || version || null,
207
+ content_sha256: data.content_sha256 || sha256Hex(Buffer.from(content, "utf8")),
208
+ updated_at: data.updated_at || null,
209
+ source_url: `${baseUrl.replace(/\/$/, "")}/fetch`,
210
+ };
211
+ return { content, meta };
212
+ }
213
+
214
+ /**
215
+ * Best-effort POST /telemetry. Silent on network errors (buffer in outbox).
216
+ * Returns {ok, status?, error?}.
217
+ */
218
+ export async function postTelemetry(baseUrl, events) {
219
+ try {
220
+ const data = await apiPost(baseUrl, "/telemetry", { events });
221
+ return { ok: true, data };
222
+ } catch (e) {
223
+ return { ok: false, error: e };
44
224
  }
225
+ }
226
+
227
+ /**
228
+ * GET /telemetry/<anonymous_id>/export → { events: [...] }.
229
+ * @param {string} baseUrl
230
+ * @param {string} anonymousId
231
+ */
232
+ export async function exportTelemetry(baseUrl, anonymousId) {
233
+ return apiGet(baseUrl, `/telemetry/${encodeURIComponent(anonymousId)}/export`);
234
+ }
235
+
236
+ /**
237
+ * DELETE /telemetry/<anonymous_id> with X-Ship-Confirm: yes.
238
+ * Returns the server JSON (expected shape: { deleted: N }).
239
+ * @param {string} baseUrl
240
+ * @param {string} anonymousId
241
+ */
242
+ export async function deleteTelemetry(baseUrl, anonymousId) {
243
+ const url = joinUrl(baseUrl, `/telemetry/${encodeURIComponent(anonymousId)}`);
244
+ const res = await fetch(url, {
245
+ method: "DELETE",
246
+ headers: {
247
+ Accept: "application/json",
248
+ "X-Ship-Confirm": "yes",
249
+ ...commonHeaders(),
250
+ },
251
+ });
252
+ const text = await res.text();
253
+ let data;
254
+ try {
255
+ data = text ? JSON.parse(text) : null;
256
+ } catch {
257
+ data = text;
258
+ }
259
+ if (!res.ok) throw new HttpError(res.status, res.statusText, url, data ?? text);
45
260
  return data;
46
261
  }
262
+
263
+ /**
264
+ * POST /feedback → { issue_url, deduplicated, ... }.
265
+ * Server errors are thrown verbatim via HttpError so callers can surface the
266
+ * server-provided message.
267
+ * @param {string} baseUrl
268
+ * @param {object} body
269
+ */
270
+ export async function postFeedback(baseUrl, body) {
271
+ return apiPost(baseUrl, "/feedback", body);
272
+ }