@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.
- package/README.md +415 -22
- package/bin/shipctl.mjs +165 -0
- package/lib/adapters/_fs.mjs +165 -0
- package/lib/adapters/agents/index.mjs +26 -0
- package/lib/adapters/ci/azure-pipelines.mjs +23 -0
- package/lib/adapters/ci/buildkite.mjs +24 -0
- package/lib/adapters/ci/circleci.mjs +23 -0
- package/lib/adapters/ci/gh-actions.mjs +29 -0
- package/lib/adapters/ci/gitlab-ci.mjs +23 -0
- package/lib/adapters/ci/jenkins.mjs +23 -0
- package/lib/adapters/ci/manual.mjs +18 -0
- package/lib/adapters/index.mjs +122 -0
- package/lib/adapters/language/dart.mjs +23 -0
- package/lib/adapters/language/go.mjs +23 -0
- package/lib/adapters/language/java.mjs +27 -0
- package/lib/adapters/language/js.mjs +32 -0
- package/lib/adapters/language/kotlin.mjs +48 -0
- package/lib/adapters/language/py.mjs +34 -0
- package/lib/adapters/language/rust.mjs +23 -0
- package/lib/adapters/language/swift.mjs +37 -0
- package/lib/adapters/language/ts.mjs +35 -0
- package/lib/adapters/trackers/azure-boards.mjs +49 -0
- package/lib/adapters/trackers/clickup.mjs +43 -0
- package/lib/adapters/trackers/github-issues.mjs +52 -0
- package/lib/adapters/trackers/jira.mjs +72 -0
- package/lib/adapters/trackers/linear.mjs +62 -0
- package/lib/adapters/trackers/none.mjs +18 -0
- package/lib/adapters/trackers/spreadsheet.mjs +28 -0
- package/lib/artifacts/fs-index.mjs +230 -0
- package/lib/bootstrap/render.mjs +373 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +302 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +1 -1
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +96 -21
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +368 -0
- package/lib/commands/lanes.mjs +502 -0
- package/lib/commands/manifest-catalog.mjs +102 -38
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +9 -43
- package/lib/commands/run.mjs +617 -0
- package/lib/commands/sync.mjs +749 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +215 -0
- package/lib/config/schema.mjs +650 -0
- package/lib/detect.mjs +162 -19
- package/lib/feedback/drafts.mjs +129 -0
- package/lib/find-ship-root.mjs +16 -10
- package/lib/http.mjs +237 -11
- package/lib/state/idempotency.mjs +183 -0
- package/lib/state/lockfile.mjs +180 -0
- package/lib/telemetry/outbox.mjs +224 -0
- package/lib/templates.mjs +53 -65
- package/lib/verify/checks/agents-on-disk.mjs +58 -0
- package/lib/verify/checks/api-reachable.mjs +39 -0
- package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
- package/lib/verify/checks/bootstrap-files.mjs +67 -0
- package/lib/verify/checks/cache-integrity.mjs +51 -0
- package/lib/verify/checks/ci-secrets.mjs +86 -0
- package/lib/verify/checks/config-present.mjs +39 -0
- package/lib/verify/checks/gitignore-cache.mjs +51 -0
- package/lib/verify/checks/rules-markers.mjs +135 -0
- package/lib/verify/checks/stack-enums.mjs +33 -0
- package/lib/verify/checks/tracker-labels.mjs +91 -0
- package/lib/verify/registry.mjs +120 -0
- package/lib/version.mjs +34 -0
- package/package.json +10 -3
- 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 {
|
|
21
|
+
* @returns {AgentTarget[]}
|
|
7
22
|
*/
|
|
8
23
|
export function detectAgentTargets(cwd) {
|
|
9
|
-
|
|
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
|
-
|
|
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: [
|
|
34
|
+
paths: [abs(".cursor", "rules", "ship-artifacts-protocol.mdc")],
|
|
35
|
+
confidence: exists(".cursor", "rules") ? 1 : 0.8,
|
|
19
36
|
});
|
|
20
37
|
}
|
|
21
38
|
|
|
22
|
-
|
|
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: [
|
|
43
|
+
paths: [abs("AGENTS.md")],
|
|
44
|
+
confidence: 1,
|
|
28
45
|
});
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
|
|
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: [
|
|
52
|
+
paths: [abs("CLAUDE.md")],
|
|
53
|
+
confidence: 1,
|
|
37
54
|
});
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
|
|
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: [
|
|
61
|
+
paths: [abs(".codex", "SHIP_API.md")],
|
|
62
|
+
confidence: 0.8,
|
|
46
63
|
});
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/find-ship-root.mjs
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
"
|
|
6
|
-
"tools
|
|
7
|
-
"collections
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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 =
|
|
40
|
+
const url = joinUrl(baseUrl, path);
|
|
8
41
|
const res = await fetch(url, {
|
|
9
42
|
method: "POST",
|
|
10
|
-
headers: {
|
|
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 =
|
|
33
|
-
const res = await fetch(url, {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
+
}
|