@elmundi/ship-cli 0.8.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +651 -25
  2. package/bin/shipctl.mjs +168 -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 +422 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +742 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +4 -4
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +159 -24
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +562 -0
  42. package/lib/commands/lanes.mjs +527 -0
  43. package/lib/commands/manifest-catalog.mjs +106 -42
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +14 -48
  47. package/lib/commands/run.mjs +857 -0
  48. package/lib/commands/search.mjs +2 -2
  49. package/lib/commands/sync.mjs +824 -0
  50. package/lib/commands/telemetry.mjs +390 -0
  51. package/lib/commands/trigger.mjs +196 -0
  52. package/lib/commands/verify.mjs +187 -0
  53. package/lib/config/io.mjs +232 -0
  54. package/lib/config/migrate.mjs +223 -0
  55. package/lib/config/schema.mjs +901 -0
  56. package/lib/detect.mjs +162 -19
  57. package/lib/feedback/drafts.mjs +129 -0
  58. package/lib/find-ship-root.mjs +16 -10
  59. package/lib/http.mjs +237 -11
  60. package/lib/state/idempotency.mjs +183 -0
  61. package/lib/state/lockfile.mjs +180 -0
  62. package/lib/telemetry/outbox.mjs +224 -0
  63. package/lib/templates.mjs +53 -65
  64. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  65. package/lib/verify/checks/api-reachable.mjs +39 -0
  66. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  67. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  68. package/lib/verify/checks/cache-integrity.mjs +51 -0
  69. package/lib/verify/checks/ci-secrets.mjs +86 -0
  70. package/lib/verify/checks/config-present.mjs +39 -0
  71. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  72. package/lib/verify/checks/rules-markers.mjs +135 -0
  73. package/lib/verify/checks/stack-enums.mjs +33 -0
  74. package/lib/verify/checks/tracker-labels.mjs +91 -0
  75. package/lib/verify/registry.mjs +120 -0
  76. package/lib/version.mjs +34 -0
  77. package/package.json +10 -3
  78. package/bin/ship.mjs +0 -68
package/lib/templates.mjs CHANGED
@@ -1,50 +1,71 @@
1
- const MARKER = "<!-- ship-cli: methodology-api -->";
1
+ const MARKER = "<!-- ship-cli: artifacts-protocol v1 -->";
2
+ const END_MARKER = "<!-- ship-cli:end artifacts-protocol -->";
2
3
 
3
4
  /**
5
+ * Short "Artifacts protocol" stub shared by every rendered template.
6
+ * Keep it tight; the authoritative contract lives in RFC-0001.
7
+ *
4
8
  * @param {string} baseUrl
5
9
  */
6
- export function cursorRuleMdc(baseUrl) {
7
- return `---
8
- name: ship-methodology-api
9
- description: Call the Ship methodology HTTP API (semantic search, fetch full docs, retro feedback, pattern index) from the agent.
10
- ---
10
+ function protocolBody(baseUrl) {
11
+ return `Base URL (env \`SHIP_API_BASE\`, flag \`--base-url\`): \`${baseUrl}\`
11
12
 
12
- ${MARKER}
13
+ ## Artifacts protocol (RFC-0001)
13
14
 
14
- # Ship methodology API (local)
15
+ Every Ship artifact is versioned (semver + \`content_sha256\`). Clients never
16
+ vendor artifact bodies — they call \`shipctl\` (which hits \`POST /fetch\` and
17
+ writes a local \`.ship/cache/<kind>/<id>@<version>/ARTIFACT.md\` entry).
15
18
 
16
- Base URL (override with \`SHIP_API_BASE\` for agents, or \`--base-url\` for CLI): \`${baseUrl}\`
19
+ Agent protocol (must follow before applying an artifact):
17
20
 
18
- ## When to use
21
+ 1. **Resolve before use.** Run \`shipctl <kind> show <id>\` (or \`fetch\`) so the
22
+ body you act on is the current pinned version. Pins live in
23
+ \`.ship/config.yml\` (\`artifacts.pins\`).
24
+ 2. **Record the exact version.** Put \`<kind>:<id>@<version>\` in the PR
25
+ description for every artifact you consumed (one per line).
26
+ 3. **Do not copy bodies into the repo.** Reference the artifact id + version.
27
+ 4. **Feedback is opt-in.** Use \`shipctl feedback draft\`; never auto-submit.
19
28
 
20
- 1. **Discover** — \`POST /search\` with a natural-language query over Ship docs + prompts.
21
- 2. **Read** — \`POST /fetch\` with a repo-relative \`path\` from search hits (markdown/text only).
22
- 3. **Retro** — \`POST /feedback\` to open a sanitized GitHub issue (needs \`GITHUB_TOKEN\` on the server).
23
- 4. **Catalog** — \`ship pattern|tool|workflow|collection list\` / \`show <id>\` / \`fetch <id>\` (\`GET /…\` on the same base URL as search, or \`POST /fetch\` with \`{ kind, id }\` when hosted; or disk when cwd/\`SHIP_REPO\` is the Ship tree).
24
-
25
- ## Examples (CLI from Ship repo)
29
+ ## CLI
26
30
 
27
31
  \`\`\`bash
28
- npm run ship -- search "release gates and qa split" --top-k 8
29
- npm run ship -- docs fetch documentation/adoption/delivery-quality-and-release-process.md
30
- npm run ship -- pattern list
32
+ shipctl pattern list
33
+ shipctl pattern show role-developer # resolves latest or pin
34
+ shipctl pattern fetch role-developer --version 1.4.2
35
+ shipctl search "release gates and qa split" --top-k 8
36
+ shipctl docs fetch documentation/adoption/delivery-quality-and-release-process.md
37
+ shipctl sync # reconcile .ship/cache/
31
38
  \`\`\`
32
39
 
33
- Equivalent curl (when not using the CLI):
40
+ ## HTTP (curl, no CLI)
34
41
 
35
42
  \`\`\`bash
36
- curl -sS -X POST "${baseUrl}/search" -H "Content-Type: application/json" \\
37
- -d '{"query":"release gates and qa split","top_k":8}'
38
43
  curl -sS -X POST "${baseUrl}/fetch" -H "Content-Type: application/json" \\
39
- -d '{"path":"documentation/adoption/delivery-quality-and-release-process.md"}'
44
+ -d '{"kind":"pattern","id":"role-developer"}'
40
45
  curl -sS "${baseUrl}/patterns"
41
46
  \`\`\`
42
47
 
43
- ## Agent workflow
48
+ See \`documentation/protocol/rfc-0001-artifacts-protocol.md\` in the Ship repo for the
49
+ full HTTP surface, pin rules, and cache layout. \`.ship/config.yml\` schema is
50
+ RFC-0002.`;
51
+ }
52
+
53
+ /**
54
+ * @param {string} baseUrl
55
+ */
56
+ export function cursorRuleMdc(baseUrl) {
57
+ return `---
58
+ name: ship-artifacts-protocol
59
+ description: Resolve, use, and record Ship artifacts (patterns/tools/collections) via shipctl.
60
+ ---
61
+
62
+ ${MARKER}
63
+
64
+ # Ship artifacts protocol (Cursor rule)
44
65
 
45
- Prefer **search → fetch one path → quote** in your reply. Never paste secrets into \`/feedback\`; the server redacts common token shapes.
66
+ ${protocolBody(baseUrl)}
46
67
 
47
- Run the API locally: \`uvicorn backend.app.main:app --reload --host 127.0.0.1 --port 8100\` (see \`documentation/tools/backend-api.md\` in the Ship repo).
68
+ ${END_MARKER}
48
69
  `;
49
70
  }
50
71
 
@@ -58,16 +79,9 @@ export function markdownSection(baseUrl) {
58
79
 
59
80
  ${MARKER}
60
81
 
61
- ## Ship methodology API
62
-
63
- Base URL: \`${baseUrl}\` (env \`SHIP_API_BASE\`).
64
-
65
- - **Search** \`POST /search\` JSON \`{ "query": string, "top_k"?: number }\`
66
- - **Fetch** \`POST /fetch\` JSON \`{ "path": "documentation/...md" }\`
67
- - **Feedback** \`POST /feedback\` JSON \`{ "title", "summary", "recommendations"?: string[], "source_context"?: string }\`
68
- - **Catalog** — \`ship pattern|tool|workflow|collection list\` / \`show <id>\` / \`fetch <id>\` (same \`SHIP_API_BASE\` as search, or local tree): \`GET /patterns\`, \`GET /patterns/{id}\`, or \`POST /fetch\` with \`{ "kind": "pattern", "id": "…" }\`
82
+ ## Ship artifacts protocol
69
83
 
70
- Use search first, then fetch the best path. Keep tokens out of feedback bodies.
84
+ ${protocolBody(baseUrl)}
71
85
  `;
72
86
  }
73
87
 
@@ -75,40 +89,14 @@ Use search first, then fetch the best path. Keep tokens out of feedback bodies.
75
89
  * @param {string} baseUrl
76
90
  */
77
91
  export function standaloneDoc(baseUrl) {
78
- return `# Ship methodology API — agent reference
92
+ return `# Ship artifacts protocol — agent reference
79
93
 
80
94
  ${MARKER}
81
95
 
82
- Generated by \`ship init\`. Base URL: \`${baseUrl}\`
83
-
84
- See the Ship repo \`documentation/tools/backend-api.md\` for full contract.
85
-
86
- ## Endpoints
87
-
88
- | Method | Path | Body / notes |
89
- |--------|------|----------------|
90
- | POST | /search | \`{ "query": "...", "top_k": 8 }\` |
91
- | POST | /fetch | \`{ "path": "documentation/foo.md" }\` |
92
- | POST | /feedback | \`{ "title", "summary", "recommendations": [], "source_context" }\` |
93
- | GET | /patterns | list manifest — **CLI:** \`ship pattern list\` (HTTP or disk in clone) |
94
- | GET | /patterns/{id} | metadata + markdown \`content\` — **CLI:** \`ship pattern show <id>\` |
95
- | POST | /fetch | catalog body — **CLI:** \`ship pattern fetch <id>\` with \`{ "kind": "pattern", "id" }\` |
96
+ Generated by \`shipctl init\`.
96
97
 
97
- ## CLI (from Ship monorepo)
98
-
99
- \`\`\`bash
100
- npm run ship -- pattern list
101
- npm run ship -- pattern show catalog-a1-intake
102
- npm run ship -- search "intake labels" --top-k 5
103
- \`\`\`
104
-
105
- ## curl (direct HTTP)
106
-
107
- \`\`\`bash
108
- export SHIP=${baseUrl}
109
- curl -sS -X POST "$SHIP/search" -H "Content-Type: application/json" -d '{"query":"intake labels","top_k":5}'
110
- \`\`\`
98
+ ${protocolBody(baseUrl)}
111
99
  `;
112
100
  }
113
101
 
114
- export { MARKER };
102
+ export { MARKER, END_MARKER };
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectAgentTargets } from "../../detect.mjs";
4
+ import { readCachedArtifact } from "../../cache/store.mjs";
5
+
6
+ export const id = "agents-on-disk";
7
+ export const category = "config";
8
+ export const description = "Declared stack.agents have detectable signals on disk";
9
+
10
+ /**
11
+ * @param {import("../registry.mjs").CheckContext} ctx
12
+ */
13
+ export async function run(ctx) {
14
+ const declared = (ctx.config && ctx.config.stack && Array.isArray(ctx.config.stack.agents))
15
+ ? ctx.config.stack.agents
16
+ : [];
17
+ if (!declared.length) {
18
+ return { status: "skip", detail: "stack.agents is empty" };
19
+ }
20
+ const detected = new Set(detectAgentTargets(ctx.cwd).map((t) => t.id));
21
+ const missing = [];
22
+ for (const agent of declared) {
23
+ if (detected.has(agent)) continue;
24
+ // Second chance: the cached agent-rules artifact may declare a custom
25
+ // install_target (e.g. codex -> AGENTS.md) that the heuristic detector
26
+ // doesn't recognise. Treat a present install_target file as "signal".
27
+ let fm = null;
28
+ try {
29
+ fm = readCachedArtifact(ctx.cwd, "collection", `agent-rules-${agent}`);
30
+ } catch {
31
+ fm = null;
32
+ }
33
+ const topLevel = fm && fm.fm && typeof fm.fm.install_target === "string"
34
+ ? fm.fm.install_target.trim()
35
+ : "";
36
+ const nested = fm && fm.spec && typeof fm.spec.install_target === "string"
37
+ ? fm.spec.install_target.trim()
38
+ : "";
39
+ const target = topLevel || nested;
40
+ if (target && fs.existsSync(path.join(ctx.cwd, target))) {
41
+ detected.add(agent);
42
+ continue;
43
+ }
44
+ missing.push(agent);
45
+ }
46
+ if (missing.length) {
47
+ return {
48
+ status: "warn",
49
+ detail: `no on-disk signal for declared agents: ${missing.join(", ")}`,
50
+ data: { missing, detected: [...detected] },
51
+ };
52
+ }
53
+ return {
54
+ status: "pass",
55
+ detail: `${declared.length} declared agent(s) have on-disk signals`,
56
+ data: { detected: [...detected] },
57
+ };
58
+ }
@@ -0,0 +1,39 @@
1
+ import { apiGet, HttpError } from "../../http.mjs";
2
+
3
+ export const id = "api-reachable";
4
+ export const category = "network";
5
+ export const description = "Ship methodology API responds on /health or /patterns";
6
+
7
+ /**
8
+ * @param {import("../registry.mjs").CheckContext} ctx
9
+ */
10
+ export async function run(ctx) {
11
+ const baseUrl = ctx.baseUrl
12
+ || (ctx.config && ctx.config.api && ctx.config.api.base_url)
13
+ || process.env.SHIP_API_BASE
14
+ || "https://ship.elmundi.com";
15
+
16
+ // Try /health first (cheap endpoint). If unavailable, fall back to
17
+ // /patterns — RFC-0005 removed the legacy /manifest aggregator, so we
18
+ // probe the smallest catalog endpoint instead. Both routes are public.
19
+ try {
20
+ await apiGet(baseUrl, "/health");
21
+ return { status: "pass", detail: `${baseUrl}/health → 200` };
22
+ } catch (e) {
23
+ if (!(e instanceof HttpError) || e.status !== 404) {
24
+ // network error OR other HTTP code — try /patterns
25
+ }
26
+ }
27
+
28
+ try {
29
+ await apiGet(baseUrl, "/patterns");
30
+ return { status: "pass", detail: `${baseUrl}/patterns → 200` };
31
+ } catch (e) {
32
+ const msg = e instanceof HttpError ? `${e.status} ${e.statusText}` : e.message;
33
+ return {
34
+ status: "fail",
35
+ detail: `${baseUrl} unreachable: ${msg}`,
36
+ data: { baseUrl },
37
+ };
38
+ }
39
+ }
@@ -0,0 +1,78 @@
1
+ import { fetchManifest } from "../../http.mjs";
2
+ import { listCached } from "../../cache/store.mjs";
3
+
4
+ export const id = "artifacts-up-to-date";
5
+ export const category = "network";
6
+ export const description = "Local cache matches latest manifest on the configured channel";
7
+
8
+ function newestVersion(versions) {
9
+ if (!Array.isArray(versions) || !versions.length) return null;
10
+ // Manifest entries are usually already sorted descending; treat the first as latest.
11
+ return versions[0];
12
+ }
13
+
14
+ /**
15
+ * @param {import("../registry.mjs").CheckContext} ctx
16
+ */
17
+ export async function run(ctx) {
18
+ let cache;
19
+ try {
20
+ cache = listCached(ctx.cwd);
21
+ } catch {
22
+ cache = [];
23
+ }
24
+ if (!cache.length) {
25
+ return { status: "skip", detail: "no cached artifacts to compare" };
26
+ }
27
+
28
+ const baseUrl = ctx.baseUrl
29
+ || (ctx.config && ctx.config.api && ctx.config.api.base_url)
30
+ || process.env.SHIP_API_BASE
31
+ || "https://ship.elmundi.com";
32
+ const channel = (ctx.config && ctx.config.api && ctx.config.api.channel) || "stable";
33
+
34
+ let manifest;
35
+ try {
36
+ manifest = await fetchManifest(baseUrl, { channel });
37
+ } catch (e) {
38
+ return { status: "warn", detail: `manifest fetch failed: ${e.message}` };
39
+ }
40
+
41
+ const index = new Map();
42
+ for (const m of manifest || []) {
43
+ const k = `${m.kind}/${m.id}`;
44
+ if (!index.has(k)) index.set(k, m);
45
+ }
46
+
47
+ const stale = [];
48
+ for (const entry of cache) {
49
+ const key = `${entry.kind}/${entry.id}`;
50
+ const m = index.get(key);
51
+ if (!m) continue;
52
+ const latest = m.version || (m.versions ? newestVersion(m.versions) : null);
53
+ if (latest && entry.version && latest !== entry.version) {
54
+ stale.push({
55
+ kind: entry.kind,
56
+ id: entry.id,
57
+ cached: entry.version,
58
+ latest,
59
+ });
60
+ }
61
+ }
62
+
63
+ if (stale.length) {
64
+ const summary = stale
65
+ .slice(0, 3)
66
+ .map((s) => `${s.kind}/${s.id} ${s.cached}→${s.latest}`)
67
+ .join(", ");
68
+ return {
69
+ status: "warn",
70
+ detail: `${stale.length} stale artifact(s): ${summary}${stale.length > 3 ? "…" : ""} — run 'shipctl sync'`,
71
+ data: { stale, channel },
72
+ };
73
+ }
74
+ return {
75
+ status: "pass",
76
+ detail: `all ${cache.length} cached entries are current on channel=${channel}`,
77
+ };
78
+ }
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const id = "bootstrap-files";
5
+ export const category = "local";
6
+ export const description = "Bootstrap scaffolding files carry ship-managed markers";
7
+
8
+ /**
9
+ * @param {import("../registry.mjs").CheckContext} ctx
10
+ */
11
+ export async function run(ctx) {
12
+ const stack = (ctx.config && ctx.config.stack) || {};
13
+ const preset = stack.preset;
14
+ const ci = stack.ci;
15
+ const tracker = stack.tracker;
16
+
17
+ if (preset !== "mobile-app" || ci !== "gh-actions" || tracker !== "linear") {
18
+ return {
19
+ status: "skip",
20
+ detail: `combo ${preset || "?"}+${ci || "?"}+${tracker || "?"} has no bootstrap template`,
21
+ };
22
+ }
23
+
24
+ const targets = [
25
+ {
26
+ rel: ".github/workflows/ship-pilot.yml",
27
+ marker: "ship-managed: workflow",
28
+ },
29
+ {
30
+ rel: ".ship/labels.yml",
31
+ marker: "ship-managed: labels",
32
+ },
33
+ {
34
+ rel: ".env.example",
35
+ marker: "--- ship-managed ---",
36
+ },
37
+ ];
38
+
39
+ const rows = [];
40
+ let fail = false;
41
+ let warn = false;
42
+ for (const t of targets) {
43
+ const abs = path.join(ctx.cwd, t.rel);
44
+ if (!fs.existsSync(abs)) {
45
+ rows.push({ path: t.rel, status: "fail", detail: "missing" });
46
+ fail = true;
47
+ continue;
48
+ }
49
+ const body = fs.readFileSync(abs, "utf8");
50
+ if (!body.includes(t.marker)) {
51
+ rows.push({
52
+ path: t.rel,
53
+ status: "warn",
54
+ detail: `present but missing marker '${t.marker}'`,
55
+ });
56
+ warn = true;
57
+ continue;
58
+ }
59
+ rows.push({ path: t.rel, status: "pass", detail: "ok" });
60
+ }
61
+
62
+ const status = fail ? "fail" : warn ? "warn" : "pass";
63
+ const detail = status === "pass"
64
+ ? `${rows.length} bootstrap files carry ship-managed markers`
65
+ : rows.filter((r) => r.status !== "pass").map((r) => `${r.path}: ${r.detail}`).join("; ");
66
+ return { status, detail, data: { rows } };
67
+ }
@@ -0,0 +1,51 @@
1
+ import { listCached, verifyCached } from "../../cache/store.mjs";
2
+
3
+ export const id = "cache-integrity";
4
+ export const category = "local";
5
+ export const description = "Cached artifact bodies match their .meta.json sha256";
6
+
7
+ /**
8
+ * @param {import("../registry.mjs").CheckContext} ctx
9
+ */
10
+ export async function run(ctx) {
11
+ let entries = [];
12
+ try {
13
+ entries = listCached(ctx.cwd);
14
+ } catch (e) {
15
+ return { status: "fail", detail: `failed to read cache index: ${e.message}` };
16
+ }
17
+ if (!entries.length) {
18
+ return { status: "skip", detail: "no cached artifacts (.ship/cache/)" };
19
+ }
20
+
21
+ const tampered = [];
22
+ for (const e of entries) {
23
+ const res = verifyCached(ctx.cwd, e.kind, e.id, e.version);
24
+ if (!res.ok) {
25
+ tampered.push({
26
+ kind: e.kind,
27
+ id: e.id,
28
+ version: e.version,
29
+ expected: res.expected,
30
+ actual: res.actual,
31
+ reason: res.reason || "sha256 mismatch",
32
+ });
33
+ }
34
+ }
35
+
36
+ if (tampered.length) {
37
+ return {
38
+ status: "fail",
39
+ detail: `${tampered.length}/${entries.length} cached entries tampered: ${tampered
40
+ .slice(0, 3)
41
+ .map((t) => `${t.kind}/${t.id}@${t.version}`)
42
+ .join(", ")}${tampered.length > 3 ? "…" : ""}`,
43
+ data: { tampered, total: entries.length },
44
+ };
45
+ }
46
+ return {
47
+ status: "pass",
48
+ detail: `${entries.length} cached entries verified (sha256 ok)`,
49
+ data: { total: entries.length },
50
+ };
51
+ }
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const id = "ci-secrets";
5
+ export const category = "network";
6
+ export const description = "Every workflow ${{ secrets.X }} reference is declared in .env.example";
7
+
8
+ const SECRET_RE = /\$\{\{\s*secrets\.([A-Z0-9_]+)\s*\}\}/g;
9
+
10
+ /**
11
+ * Extract secret names referenced in a gh-actions workflow body.
12
+ */
13
+ function extractSecrets(body) {
14
+ const found = new Set();
15
+ let m;
16
+ // Reset regex across calls
17
+ const re = new RegExp(SECRET_RE.source, SECRET_RE.flags);
18
+ while ((m = re.exec(body)) !== null) {
19
+ const name = m[1];
20
+ if (name && name !== "GITHUB_TOKEN") found.add(name);
21
+ }
22
+ return [...found];
23
+ }
24
+
25
+ function parseEnvExample(body) {
26
+ const out = new Set();
27
+ for (const raw of body.split(/\r?\n/)) {
28
+ const line = raw.trim();
29
+ if (!line || line.startsWith("#")) continue;
30
+ const eq = line.indexOf("=");
31
+ if (eq < 0) continue;
32
+ const key = line.slice(0, eq).trim();
33
+ if (/^[A-Z0-9_]+$/.test(key)) out.add(key);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * @param {import("../registry.mjs").CheckContext} ctx
40
+ */
41
+ export async function run(ctx) {
42
+ const ci = ctx.config && ctx.config.stack && ctx.config.stack.ci;
43
+ if (ci !== "gh-actions") {
44
+ return { status: "skip", detail: `stack.ci=${ci || "?"} (gh-actions-only check)` };
45
+ }
46
+
47
+ const wfDir = path.join(ctx.cwd, ".github", "workflows");
48
+ if (!fs.existsSync(wfDir)) {
49
+ return { status: "skip", detail: ".github/workflows not present" };
50
+ }
51
+ let files;
52
+ try {
53
+ files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f));
54
+ } catch {
55
+ files = [];
56
+ }
57
+ if (!files.length) {
58
+ return { status: "skip", detail: "no workflow files under .github/workflows" };
59
+ }
60
+
61
+ const referenced = new Set();
62
+ for (const f of files) {
63
+ const body = fs.readFileSync(path.join(wfDir, f), "utf8");
64
+ for (const s of extractSecrets(body)) referenced.add(s);
65
+ }
66
+ if (!referenced.size) {
67
+ return { status: "pass", detail: `no ${"${{ secrets.* }}"} references in ${files.length} workflow(s)` };
68
+ }
69
+
70
+ const envExamplePath = path.join(ctx.cwd, ".env.example");
71
+ const declared = fs.existsSync(envExamplePath)
72
+ ? parseEnvExample(fs.readFileSync(envExamplePath, "utf8"))
73
+ : new Set();
74
+ const missing = [...referenced].filter((s) => !declared.has(s));
75
+ if (!missing.length) {
76
+ return {
77
+ status: "pass",
78
+ detail: `all ${referenced.size} referenced secret(s) declared in .env.example`,
79
+ };
80
+ }
81
+ return {
82
+ status: "warn",
83
+ detail: `secrets referenced in workflows but missing from .env.example: ${missing.join(", ")}`,
84
+ data: { missing, referenced: [...referenced] },
85
+ };
86
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { validateConfig } from "../../config/schema.mjs";
4
+
5
+ export const id = "config-present";
6
+ export const category = "local";
7
+ export const description = ".ship/config.yml exists and validates";
8
+
9
+ /**
10
+ * @param {import("../registry.mjs").CheckContext} ctx
11
+ */
12
+ export async function run(ctx) {
13
+ const configPath = path.join(ctx.cwd, ".ship", "config.yml");
14
+ if (!fs.existsSync(configPath)) {
15
+ return {
16
+ status: "fail",
17
+ detail: `missing ${path.relative(ctx.cwd, configPath)} — run 'shipctl config init'`,
18
+ };
19
+ }
20
+ if (!ctx.config) {
21
+ return {
22
+ status: "fail",
23
+ detail: `${path.relative(ctx.cwd, configPath)} could not be parsed`,
24
+ };
25
+ }
26
+ const res = validateConfig(ctx.config);
27
+ if (!res.ok) {
28
+ return {
29
+ status: "fail",
30
+ detail: `${path.relative(ctx.cwd, configPath)} invalid: ${res.errors[0]}`,
31
+ data: { errors: res.errors, warnings: res.warnings },
32
+ };
33
+ }
34
+ return {
35
+ status: "pass",
36
+ detail: `${path.relative(ctx.cwd, configPath)} parsed; schema v${ctx.config.version ?? "?"}`,
37
+ data: { warnings: res.warnings },
38
+ };
39
+ }
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const id = "gitignore-cache";
5
+ export const category = "local";
6
+ export const description = ".gitignore contains .ship/cache/";
7
+
8
+ /**
9
+ * @param {import("../registry.mjs").CheckContext} ctx
10
+ */
11
+ export async function run(ctx) {
12
+ const cacheTracked =
13
+ !!(ctx.config && ctx.config.cache && ctx.config.cache.vcs_tracked === true);
14
+ const giPath = path.join(ctx.cwd, ".gitignore");
15
+ if (!fs.existsSync(giPath)) {
16
+ if (cacheTracked) {
17
+ return {
18
+ status: "pass",
19
+ detail: ".gitignore absent but cache.vcs_tracked=true — cache is intentionally committed",
20
+ };
21
+ }
22
+ return {
23
+ status: "warn",
24
+ detail: ".gitignore not found; add `.ship/cache/` to keep cached artifacts out of git",
25
+ };
26
+ }
27
+ const body = fs.readFileSync(giPath, "utf8");
28
+ const lines = new Set(body.split(/\r?\n/).map((l) => l.trim()));
29
+ const listed = lines.has(".ship/cache/") || lines.has(".ship/cache");
30
+
31
+ if (cacheTracked && listed) {
32
+ return {
33
+ status: "warn",
34
+ detail:
35
+ ".ship/cache/ listed in .gitignore but cache.vcs_tracked=true — entries will be ignored by git",
36
+ };
37
+ }
38
+ if (cacheTracked) {
39
+ return {
40
+ status: "pass",
41
+ detail: "cache.vcs_tracked=true; .gitignore does not exclude .ship/cache/",
42
+ };
43
+ }
44
+ if (!listed) {
45
+ return {
46
+ status: "warn",
47
+ detail: ".ship/cache/ not listed in .gitignore — add it to avoid committing cached bodies",
48
+ };
49
+ }
50
+ return { status: "pass", detail: ".ship/cache/ listed in .gitignore" };
51
+ }