@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.
- package/README.md +651 -25
- package/bin/shipctl.mjs +168 -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 +422 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +742 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +159 -24
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +562 -0
- package/lib/commands/lanes.mjs +527 -0
- package/lib/commands/manifest-catalog.mjs +106 -42
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +14 -48
- package/lib/commands/run.mjs +857 -0
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +824 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/trigger.mjs +196 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +223 -0
- package/lib/config/schema.mjs +901 -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/templates.mjs
CHANGED
|
@@ -1,50 +1,71 @@
|
|
|
1
|
-
const MARKER = "<!-- ship-cli:
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
## Artifacts protocol (RFC-0001)
|
|
13
14
|
|
|
14
|
-
|
|
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
|
-
|
|
19
|
+
Agent protocol (must follow before applying an artifact):
|
|
17
20
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 '{"
|
|
44
|
+
-d '{"kind":"pattern","id":"role-developer"}'
|
|
40
45
|
curl -sS "${baseUrl}/patterns"
|
|
41
46
|
\`\`\`
|
|
42
47
|
|
|
43
|
-
|
|
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
|
-
|
|
66
|
+
${protocolBody(baseUrl)}
|
|
46
67
|
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
92
|
+
return `# Ship artifacts protocol — agent reference
|
|
79
93
|
|
|
80
94
|
${MARKER}
|
|
81
95
|
|
|
82
|
-
Generated by \`
|
|
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
|
-
|
|
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
|
+
}
|