@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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared filesystem index for v2 artifact trees: walks
|
|
6
|
+
* `<repoRoot>/artifacts/<plural>/<id>/ARTIFACT.md` and parses just enough YAML
|
|
7
|
+
* front-matter to reconstruct the same entry shape we used to read out of the
|
|
8
|
+
* legacy `<plural>/manifest.json` files.
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies — only node builtins. Anything we cannot parse falls
|
|
11
|
+
* through (the entry still gets emitted with whatever fields we recovered).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const KIND_TO_PLURAL = {
|
|
15
|
+
pattern: "patterns",
|
|
16
|
+
tool: "tools",
|
|
17
|
+
collection: "collections",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {"pattern"|"tool"|"collection"} kind
|
|
22
|
+
*/
|
|
23
|
+
export function pluralFor(kind) {
|
|
24
|
+
return KIND_TO_PLURAL[kind] || `${kind}s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walk `artifacts/<plural>/*` and return the parsed entries (same shape as the
|
|
29
|
+
* legacy manifest).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} repoRoot
|
|
32
|
+
* @param {"pattern"|"tool"|"collection"} kind
|
|
33
|
+
* @returns {Array<Record<string, any>>}
|
|
34
|
+
*/
|
|
35
|
+
export function scanArtifacts(repoRoot, kind) {
|
|
36
|
+
const plural = pluralFor(kind);
|
|
37
|
+
const dir = path.join(repoRoot, "artifacts", plural);
|
|
38
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
|
|
39
|
+
|
|
40
|
+
const ids = fs.readdirSync(dir, { withFileTypes: true })
|
|
41
|
+
.filter((e) => e.isDirectory())
|
|
42
|
+
.map((e) => e.name)
|
|
43
|
+
.sort();
|
|
44
|
+
|
|
45
|
+
/** @type {Array<Record<string, any>>} */
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const id of ids) {
|
|
48
|
+
const file = path.join(dir, id, "ARTIFACT.md");
|
|
49
|
+
if (!fs.existsSync(file)) continue;
|
|
50
|
+
let raw;
|
|
51
|
+
try {
|
|
52
|
+
raw = fs.readFileSync(file, "utf8");
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const { fm } = parseFrontMatter(raw);
|
|
57
|
+
const entry = entryFromFrontmatter(fm, kind, id);
|
|
58
|
+
out.push(entry);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the full ARTIFACT.md (frontmatter + body) for a specific id. Returns
|
|
65
|
+
* null when the file is absent so callers can emit the same "Unknown id"
|
|
66
|
+
* messages they did before.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} repoRoot
|
|
69
|
+
* @param {"pattern"|"tool"|"collection"} kind
|
|
70
|
+
* @param {string} id
|
|
71
|
+
*/
|
|
72
|
+
export function readArtifactFile(repoRoot, kind, id) {
|
|
73
|
+
const plural = pluralFor(kind);
|
|
74
|
+
const file = path.join(repoRoot, "artifacts", plural, id, "ARTIFACT.md");
|
|
75
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return null;
|
|
76
|
+
return { absPath: file, content: fs.readFileSync(file, "utf8") };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function entryFromFrontmatter(fm, kind, id) {
|
|
80
|
+
const plural = pluralFor(kind);
|
|
81
|
+
const description = typeof fm.description === "string" ? fm.description : "";
|
|
82
|
+
const summary = description ? firstSentence(description) : "";
|
|
83
|
+
return {
|
|
84
|
+
id: typeof fm.id === "string" && fm.id ? fm.id : id,
|
|
85
|
+
title: typeof fm.name === "string" ? fm.name : id,
|
|
86
|
+
summary,
|
|
87
|
+
path: `artifacts/${plural}/${id}/ARTIFACT.md`,
|
|
88
|
+
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
89
|
+
group: typeof fm.group === "string" ? fm.group : null,
|
|
90
|
+
version: typeof fm.version === "string" ? fm.version : null,
|
|
91
|
+
content_sha256: typeof fm.content_sha256 === "string" ? fm.content_sha256 : null,
|
|
92
|
+
updated_at: typeof fm.updated_at === "string" ? fm.updated_at : null,
|
|
93
|
+
channel: typeof fm.channel === "string" ? fm.channel : null,
|
|
94
|
+
min_shipctl: typeof fm.min_shipctl === "string" ? fm.min_shipctl : null,
|
|
95
|
+
deprecated: fm.deprecated === true || fm.deprecated === "true",
|
|
96
|
+
replaced_by: fm.replaced_by ?? null,
|
|
97
|
+
yanked: fm.yanked === true || fm.yanked === "true",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function firstSentence(text) {
|
|
102
|
+
const trimmed = text.trim();
|
|
103
|
+
if (!trimmed) return "";
|
|
104
|
+
const m = /[.!?](\s|$)/.exec(trimmed);
|
|
105
|
+
if (!m) return trimmed;
|
|
106
|
+
return trimmed.slice(0, m.index + 1).trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tiny YAML front-matter parser tailored for v2 ARTIFACT.md files.
|
|
111
|
+
*
|
|
112
|
+
* Supports:
|
|
113
|
+
* - simple `key: value`
|
|
114
|
+
* - inline lists `key: [a, b]`
|
|
115
|
+
* - folded scalars `key: >` / `key: >-` with indented continuation lines
|
|
116
|
+
* - quoted strings (single or double)
|
|
117
|
+
* - one level of nested mapping (used by `spec:`)
|
|
118
|
+
* - comments (`# …`)
|
|
119
|
+
*
|
|
120
|
+
* Anything else is best-effort: the value is captured as the trimmed string.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} source
|
|
123
|
+
* @returns {{fm: Record<string, any>, body: string}}
|
|
124
|
+
*/
|
|
125
|
+
export function parseFrontMatter(source) {
|
|
126
|
+
if (typeof source !== "string") return { fm: {}, body: "" };
|
|
127
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
|
|
128
|
+
if (!match) return { fm: {}, body: source };
|
|
129
|
+
const block = match[1];
|
|
130
|
+
const body = source.slice(match[0].length);
|
|
131
|
+
/** @type {Record<string, any>} */
|
|
132
|
+
const fm = {};
|
|
133
|
+
const lines = block.split(/\r?\n/);
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < lines.length) {
|
|
136
|
+
const rawLine = lines[i];
|
|
137
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
138
|
+
if (!line || /^\s*#/.test(line)) {
|
|
139
|
+
i += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const top = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(line);
|
|
143
|
+
if (!top) {
|
|
144
|
+
i += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const key = top[1];
|
|
148
|
+
const value = top[2];
|
|
149
|
+
|
|
150
|
+
if (value === ">" || value === ">-") {
|
|
151
|
+
const folded = [];
|
|
152
|
+
i += 1;
|
|
153
|
+
while (i < lines.length) {
|
|
154
|
+
const cont = lines[i];
|
|
155
|
+
if (cont === "" || cont === "\r") {
|
|
156
|
+
// Preserve paragraph breaks as a single space in folded scalars.
|
|
157
|
+
folded.push("");
|
|
158
|
+
i += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const m = /^(\s+)(.*)$/.exec(cont);
|
|
162
|
+
if (!m) break;
|
|
163
|
+
folded.push(m[2]);
|
|
164
|
+
i += 1;
|
|
165
|
+
}
|
|
166
|
+
let joined = folded.join(" ").replace(/\s+/g, " ").trim();
|
|
167
|
+
if (value === ">-") joined = joined.replace(/\s+$/, "");
|
|
168
|
+
fm[key] = joined;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (/^\[.*\]$/.test(value.trim())) {
|
|
173
|
+
const inner = value.trim().slice(1, -1).trim();
|
|
174
|
+
fm[key] = inner.length ? inner.split(/\s*,\s*/).map(unquote) : [];
|
|
175
|
+
i += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (value === "") {
|
|
180
|
+
// Possible nested mapping or empty scalar. Peek ahead.
|
|
181
|
+
const child = {};
|
|
182
|
+
let saw = false;
|
|
183
|
+
let j = i + 1;
|
|
184
|
+
while (j < lines.length) {
|
|
185
|
+
const cont = lines[j];
|
|
186
|
+
if (!cont.trim()) { j += 1; continue; }
|
|
187
|
+
const indented = /^(\s{2,})([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(cont);
|
|
188
|
+
if (!indented) break;
|
|
189
|
+
const [, , subKey, subVal] = indented;
|
|
190
|
+
if (/^\[.*\]$/.test(subVal.trim())) {
|
|
191
|
+
const inner = subVal.trim().slice(1, -1).trim();
|
|
192
|
+
child[subKey] = inner.length ? inner.split(/\s*,\s*/).map(unquote) : [];
|
|
193
|
+
} else {
|
|
194
|
+
child[subKey] = coerceScalar(subVal);
|
|
195
|
+
}
|
|
196
|
+
saw = true;
|
|
197
|
+
j += 1;
|
|
198
|
+
}
|
|
199
|
+
if (saw) {
|
|
200
|
+
fm[key] = child;
|
|
201
|
+
i = j;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
fm[key] = "";
|
|
205
|
+
i += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fm[key] = coerceScalar(value);
|
|
210
|
+
i += 1;
|
|
211
|
+
}
|
|
212
|
+
return { fm, body };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function unquote(value) {
|
|
216
|
+
if (typeof value !== "string") return value;
|
|
217
|
+
const v = value.trim();
|
|
218
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
219
|
+
return v.slice(1, -1);
|
|
220
|
+
}
|
|
221
|
+
return v;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function coerceScalar(rawValue) {
|
|
225
|
+
const v = unquote(String(rawValue).trim());
|
|
226
|
+
if (v === "true") return true;
|
|
227
|
+
if (v === "false") return false;
|
|
228
|
+
if (v === "null" || v === "~") return null;
|
|
229
|
+
return v;
|
|
230
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bootstrap renderer for `shipctl init --bootstrap`.
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally a v1: full preset-body interpretation (parsing
|
|
8
|
+
* `## Bootstrap (files to write)` blocks from adapter artifacts) is TODO
|
|
9
|
+
* and tracked as a templating engine in RFC-0004. For now we:
|
|
10
|
+
*
|
|
11
|
+
* - Always emit a SHIP_BOOTSTRAP_PLAN.md summary so the user has a
|
|
12
|
+
* single actionable next-step document.
|
|
13
|
+
* - For the common `mobile-app + gh-actions + linear` triple we also
|
|
14
|
+
* write minimal CI workflow skeleton, label contract YAML, and
|
|
15
|
+
* `.env.example` placeholders. Other combos fall back to plan-only.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {Object} PlanFile
|
|
18
|
+
* @property {string} path Relative to cwd.
|
|
19
|
+
* @property {string} content
|
|
20
|
+
* @property {"create"|"append"|"patch"} mode
|
|
21
|
+
*
|
|
22
|
+
* @typedef {Object} PlanSummary
|
|
23
|
+
* @property {string[]} notes
|
|
24
|
+
* @property {Array<{path:string, mode:string, detail?:string}>} files
|
|
25
|
+
*
|
|
26
|
+
* @typedef {Object} RenderedPlan
|
|
27
|
+
* @property {PlanFile[]} files
|
|
28
|
+
* @property {PlanSummary} summary
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const MOBILE_LABELS = [
|
|
32
|
+
"platform:ios",
|
|
33
|
+
"platform:android",
|
|
34
|
+
"store:review",
|
|
35
|
+
"flag:behind",
|
|
36
|
+
"flag:ahead",
|
|
37
|
+
"change-record",
|
|
38
|
+
"blocked",
|
|
39
|
+
"preview:ready",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const ENV_EXAMPLE_MARKER_START = "# --- ship-managed ---";
|
|
43
|
+
const ENV_EXAMPLE_MARKER_END = "# --- end ship-managed ---";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} cfg
|
|
47
|
+
* @returns {RenderedPlan}
|
|
48
|
+
*/
|
|
49
|
+
export function renderMobileAppGhActionsLinear(cfg) {
|
|
50
|
+
const preset = cfg.stack?.preset || "mobile-app";
|
|
51
|
+
const tracker = cfg.stack?.tracker || "linear";
|
|
52
|
+
const ci = cfg.stack?.ci || "gh-actions";
|
|
53
|
+
const agents = Array.isArray(cfg.stack?.agents) ? cfg.stack.agents : [];
|
|
54
|
+
|
|
55
|
+
const workflow = `# ship-managed: workflow
|
|
56
|
+
# Skeleton written by \`shipctl init --bootstrap\`.
|
|
57
|
+
# shipctl sync (Epic 7) will fill in job bodies from preset:preset-${preset}.
|
|
58
|
+
name: ship-pilot
|
|
59
|
+
on:
|
|
60
|
+
pull_request:
|
|
61
|
+
push:
|
|
62
|
+
branches: [main]
|
|
63
|
+
|
|
64
|
+
jobs:
|
|
65
|
+
# ship-managed: workflow
|
|
66
|
+
lint:
|
|
67
|
+
runs-on: ubuntu-latest
|
|
68
|
+
steps:
|
|
69
|
+
- uses: actions/checkout@v4
|
|
70
|
+
# TODO: language-specific lint wired by shipctl sync
|
|
71
|
+
- run: echo "lint: placeholder"
|
|
72
|
+
|
|
73
|
+
# ship-managed: workflow
|
|
74
|
+
build-ios:
|
|
75
|
+
runs-on: macos-latest
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/checkout@v4
|
|
78
|
+
# TODO: EAS / Fastlane build steps wired by shipctl sync
|
|
79
|
+
- run: echo "build-ios: placeholder"
|
|
80
|
+
|
|
81
|
+
# ship-managed: workflow
|
|
82
|
+
build-android:
|
|
83
|
+
runs-on: ubuntu-latest
|
|
84
|
+
steps:
|
|
85
|
+
- uses: actions/checkout@v4
|
|
86
|
+
# TODO: Gradle / EAS build steps wired by shipctl sync
|
|
87
|
+
- run: echo "build-android: placeholder"
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const labelsYml = `# ship-managed: labels
|
|
91
|
+
# Synced to the tracker (${tracker}) by \`shipctl verify\`.
|
|
92
|
+
version: 1
|
|
93
|
+
preset: ${preset}
|
|
94
|
+
labels:
|
|
95
|
+
${MOBILE_LABELS.map((l) => ` - name: "${l}"`).join("\n")}
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const envBlock = `${ENV_EXAMPLE_MARKER_START}
|
|
99
|
+
# Placeholders for ${preset} / ${tracker} / ${ci}.
|
|
100
|
+
# Fill these in .env.local (not committed) or your platform secret store.
|
|
101
|
+
LINEAR_API_KEY=
|
|
102
|
+
LINEAR_TEAM_ID=
|
|
103
|
+
GITHUB_TOKEN=
|
|
104
|
+
EXPO_TOKEN=
|
|
105
|
+
SENTRY_AUTH_TOKEN=
|
|
106
|
+
${ENV_EXAMPLE_MARKER_END}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
const plan = renderAdoptionMinimum(cfg, {
|
|
110
|
+
extraNotes: [
|
|
111
|
+
"Mobile-app pilot scaffolding was emitted (gh-actions + linear).",
|
|
112
|
+
"See `.github/workflows/ship-pilot.yml`, `.ship/labels.yml`, `.env.example`.",
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const files = [
|
|
117
|
+
...plan.files,
|
|
118
|
+
{
|
|
119
|
+
path: ".github/workflows/ship-pilot.yml",
|
|
120
|
+
content: workflow,
|
|
121
|
+
mode: /** @type {"create"} */ ("create"),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
path: ".ship/labels.yml",
|
|
125
|
+
content: labelsYml,
|
|
126
|
+
mode: /** @type {"create"} */ ("create"),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
path: ".env.example",
|
|
130
|
+
content: envBlock,
|
|
131
|
+
mode: /** @type {"append"} */ ("append"),
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const summary = {
|
|
136
|
+
notes: [
|
|
137
|
+
...plan.summary.notes,
|
|
138
|
+
`bootstrap: mobile-app + ${ci} + ${tracker} triple rendered`,
|
|
139
|
+
`agents: ${agents.join(", ") || "(none)"}`,
|
|
140
|
+
],
|
|
141
|
+
files: files.map((f) => ({
|
|
142
|
+
path: f.path,
|
|
143
|
+
mode: f.mode,
|
|
144
|
+
detail:
|
|
145
|
+
f.path === ".ship/labels.yml"
|
|
146
|
+
? `${MOBILE_LABELS.length} labels`
|
|
147
|
+
: f.path === ".env.example"
|
|
148
|
+
? "5 placeholders"
|
|
149
|
+
: undefined,
|
|
150
|
+
})),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return { files, summary };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Generate just the SHIP_BOOTSTRAP_PLAN.md summary. Used as the v1
|
|
158
|
+
* fallback for preset / CI / tracker combos that don't have a specific
|
|
159
|
+
* renderer yet.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} cfg
|
|
162
|
+
* @param {{extraNotes?:string[]}} [opts]
|
|
163
|
+
* @returns {RenderedPlan}
|
|
164
|
+
*/
|
|
165
|
+
export function renderAdoptionMinimum(cfg, opts = {}) {
|
|
166
|
+
const stack = cfg.stack || {};
|
|
167
|
+
const preset = stack.preset || "adoption-minimum";
|
|
168
|
+
const tracker = stack.tracker || "none";
|
|
169
|
+
const ci = stack.ci || "manual";
|
|
170
|
+
const agents = Array.isArray(stack.agents) ? stack.agents : [];
|
|
171
|
+
const language = stack.language || "multi";
|
|
172
|
+
const channel = cfg.api?.channel || "stable";
|
|
173
|
+
const telemetry = cfg.telemetry?.share === true ? "on" : "off";
|
|
174
|
+
const extraNotes = opts.extraNotes || [];
|
|
175
|
+
|
|
176
|
+
const todos = buildTodoList({ preset, ci, tracker, agents });
|
|
177
|
+
const recommendedTools = buildRecommendedTools({ preset });
|
|
178
|
+
const recommendedSecrets = buildRecommendedSecrets({ tracker, ci });
|
|
179
|
+
|
|
180
|
+
const body = `# Ship bootstrap plan
|
|
181
|
+
|
|
182
|
+
_Generated by \`shipctl init --bootstrap\` on ${new Date().toISOString()}._
|
|
183
|
+
|
|
184
|
+
## Chosen stack
|
|
185
|
+
|
|
186
|
+
- **preset**: \`${preset}\`
|
|
187
|
+
- **tracker**: \`${tracker}\`
|
|
188
|
+
- **ci**: \`${ci}\`
|
|
189
|
+
- **language**: \`${language}\`
|
|
190
|
+
- **agents**: ${agents.length ? agents.map((a) => `\`${a}\``).join(", ") : "_(none)_"}
|
|
191
|
+
- **channel**: \`${channel}\`
|
|
192
|
+
- **telemetry**: \`${telemetry}\`
|
|
193
|
+
|
|
194
|
+
## Recommended tools
|
|
195
|
+
|
|
196
|
+
${recommendedTools.map((t) => `- ${t}`).join("\n") || "_(none for this preset yet — fill manually.)_"}
|
|
197
|
+
|
|
198
|
+
## Recommended secrets / env
|
|
199
|
+
|
|
200
|
+
${recommendedSecrets.map((s) => `- \`${s}\``).join("\n") || "_(none required.)_"}
|
|
201
|
+
|
|
202
|
+
## Files to create / review
|
|
203
|
+
|
|
204
|
+
${todos.map((t) => `- [ ] ${t}`).join("\n")}
|
|
205
|
+
|
|
206
|
+
## Next steps
|
|
207
|
+
|
|
208
|
+
1. \`shipctl sync\` to refresh \`.ship/cache/\` against the Ship API.
|
|
209
|
+
2. \`shipctl verify\` to confirm tracker labels / CI secrets / rules markers.
|
|
210
|
+
3. Open the preset artifact for full details:
|
|
211
|
+
\`shipctl collection show preset-${preset}\`.
|
|
212
|
+
|
|
213
|
+
${
|
|
214
|
+
extraNotes.length
|
|
215
|
+
? `## Notes\n\n${extraNotes.map((n) => `- ${n}`).join("\n")}\n`
|
|
216
|
+
: ""
|
|
217
|
+
}`;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
files: [
|
|
221
|
+
{
|
|
222
|
+
path: "SHIP_BOOTSTRAP_PLAN.md",
|
|
223
|
+
content: body,
|
|
224
|
+
mode: /** @type {"create"} */ ("create"),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
summary: {
|
|
228
|
+
notes: ["bootstrap: plan-only fallback rendered (SHIP_BOOTSTRAP_PLAN.md)"],
|
|
229
|
+
files: [
|
|
230
|
+
{
|
|
231
|
+
path: "SHIP_BOOTSTRAP_PLAN.md",
|
|
232
|
+
mode: "create",
|
|
233
|
+
detail: `${todos.length} todo items`,
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildTodoList({ preset, ci, tracker, agents }) {
|
|
241
|
+
const todos = [];
|
|
242
|
+
if (ci === "gh-actions") {
|
|
243
|
+
todos.push("Confirm `.github/workflows/ship-pilot.yml` skeleton (shipctl sync will fill the job bodies).");
|
|
244
|
+
} else {
|
|
245
|
+
todos.push(`Author the CI workflow skeleton for \`${ci}\` manually (no renderer yet).`);
|
|
246
|
+
}
|
|
247
|
+
if (tracker !== "none") {
|
|
248
|
+
todos.push(`Create the label contract for \`${tracker}\` (see preset:preset-${preset} for the label set).`);
|
|
249
|
+
}
|
|
250
|
+
for (const a of agents) {
|
|
251
|
+
todos.push(`Agent rules for \`${a}\`: install via \`shipctl init --copy-rules --agents ${a}\`.`);
|
|
252
|
+
}
|
|
253
|
+
todos.push("Populate `.env.example` / secret store with the secrets listed above.");
|
|
254
|
+
todos.push("Run `shipctl verify` after the above to confirm the stack.");
|
|
255
|
+
return todos;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildRecommendedTools({ preset }) {
|
|
259
|
+
const common = ["`shipctl doctor` — inspect repo and reconcile stack"];
|
|
260
|
+
const byPreset = {
|
|
261
|
+
"mobile-app": [
|
|
262
|
+
"EAS Build / Fastlane for iOS + Android signed builds",
|
|
263
|
+
"Detox or Maestro for device-farm E2E",
|
|
264
|
+
"Expo Updates or CodePush for OTA patches",
|
|
265
|
+
],
|
|
266
|
+
"mobile-app-deep": [
|
|
267
|
+
"EAS Build / Fastlane for iOS + Android signed builds",
|
|
268
|
+
"Detox or Maestro for device-farm E2E",
|
|
269
|
+
"Expo Updates or CodePush for OTA patches",
|
|
270
|
+
"Crashlytics or Sentry for post-release crash-rate tracking",
|
|
271
|
+
"TestFlight external testing + Play closed-testing tracks",
|
|
272
|
+
],
|
|
273
|
+
"ml-project": [
|
|
274
|
+
"DVC / LakeFS / Delta Lake for dataset + model versioning",
|
|
275
|
+
"MLflow or Weights & Biases for eval tracking",
|
|
276
|
+
"Feast / Tecton / Databricks Feature Store for feature contracts",
|
|
277
|
+
"Great Expectations / Pandera for data validation",
|
|
278
|
+
],
|
|
279
|
+
platform: [
|
|
280
|
+
"Terraform + a state backend you can audit (S3 / Terraform Cloud)",
|
|
281
|
+
"Kyverno / OPA / Conftest for manifest-level policy gating",
|
|
282
|
+
"Infracost or cloud-pricing API for per-PR cost deltas",
|
|
283
|
+
"Prometheus / Datadog / CloudWatch for SLO burn-rate queries",
|
|
284
|
+
"Syft / Trivy / Grype for SBOM generation at release",
|
|
285
|
+
],
|
|
286
|
+
regulated: [
|
|
287
|
+
"Presidio / Nightfall / Transcend for PII detection helpers",
|
|
288
|
+
"An audit log store with hash-chain integrity (append-only DB / S3 with object-lock)",
|
|
289
|
+
"Vault / AWS Secrets Manager for scoped compliance credentials",
|
|
290
|
+
"Drata / Vanta / Secureframe for evidence-bundle sync (optional)",
|
|
291
|
+
],
|
|
292
|
+
"desktop-app": [
|
|
293
|
+
"Apple notarytool + Developer ID cert for macOS notarization",
|
|
294
|
+
"Windows Authenticode / EV code-signing cert + SignTool",
|
|
295
|
+
"electron-updater / Sparkle / Squirrel for staged auto-update",
|
|
296
|
+
"A crash-telemetry SDK (Sentry / Bugsnag) scoped to desktop builds",
|
|
297
|
+
"Per-platform installer toolchain (dmg, msi, deb, AppImage)",
|
|
298
|
+
],
|
|
299
|
+
firmware: [
|
|
300
|
+
"A cross-toolchain (arm-none-eabi-gcc / clang-embedded / IDF / Zephyr SDK)",
|
|
301
|
+
"PlatformIO / ESP-IDF / Zephyr / Yocto build system tied into CI",
|
|
302
|
+
"Renode or QEMU for simulated HIL runs on PR",
|
|
303
|
+
"An OTA backend (AWS IoT Jobs / Azure IoT Hub / Mender / Balena / Nerves)",
|
|
304
|
+
"Octopart / Digi-Key / Mouser API for BOM pricing + lifecycle",
|
|
305
|
+
"A bench power analyzer or logged DMM for power-profile ground truth",
|
|
306
|
+
],
|
|
307
|
+
game: [
|
|
308
|
+
"Your engine (Unity / Unreal / Godot) hooked into CI with headless builds",
|
|
309
|
+
"Perforce or Git LFS for binary art assets",
|
|
310
|
+
"Unity Cloud Build / UGS / Jenkins for per-PR cook builds",
|
|
311
|
+
"RenderDoc / Tracy / Unreal Insights for frametime capture",
|
|
312
|
+
"A crash-telemetry SDK (Backtrace / Sentry / Unity Cloud Diagnostics)",
|
|
313
|
+
"A localization TMS (Crowdin / Lokalise / Smartling) for event strings",
|
|
314
|
+
],
|
|
315
|
+
"web-app": [
|
|
316
|
+
"Playwright (hosted) for PR preview E2E",
|
|
317
|
+
"Preview deployments (Vercel / Netlify / Fly) per PR",
|
|
318
|
+
],
|
|
319
|
+
"api-backend": [
|
|
320
|
+
"Contract tests (Pact / OpenAPI diff)",
|
|
321
|
+
"Migration discipline (Atlas / Liquibase)",
|
|
322
|
+
],
|
|
323
|
+
cli: ["Cross-platform release matrix (GoReleaser / pkg / esbuild)"],
|
|
324
|
+
monorepo: ["Turborepo / Nx / pnpm workspaces for per-package CI"],
|
|
325
|
+
"adoption-minimum": [],
|
|
326
|
+
};
|
|
327
|
+
return [...common, ...(byPreset[preset] || [])];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function buildRecommendedSecrets({ tracker, ci }) {
|
|
331
|
+
const secrets = new Set();
|
|
332
|
+
if (tracker === "linear") secrets.add("LINEAR_API_KEY").add("LINEAR_TEAM_ID");
|
|
333
|
+
if (tracker === "jira") secrets.add("JIRA_API_TOKEN").add("JIRA_EMAIL");
|
|
334
|
+
if (tracker === "github-issues") secrets.add("GITHUB_TOKEN");
|
|
335
|
+
if (ci === "gh-actions") secrets.add("GITHUB_TOKEN");
|
|
336
|
+
if (ci === "circleci") secrets.add("CIRCLE_TOKEN");
|
|
337
|
+
return [...secrets];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Pick the right renderer for this stack. v1 only special-cases
|
|
342
|
+
* `mobile-app + gh-actions + linear`.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} cfg
|
|
345
|
+
* @returns {RenderedPlan}
|
|
346
|
+
*/
|
|
347
|
+
export function renderPlan(cfg /*, presetArtifact */) {
|
|
348
|
+
const preset = cfg.stack?.preset;
|
|
349
|
+
const tracker = cfg.stack?.tracker;
|
|
350
|
+
const ci = cfg.stack?.ci;
|
|
351
|
+
|
|
352
|
+
if (preset === "mobile-app" && ci === "gh-actions" && tracker === "linear") {
|
|
353
|
+
return renderMobileAppGhActionsLinear(cfg);
|
|
354
|
+
}
|
|
355
|
+
return renderAdoptionMinimum(cfg);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Apply a plan to disk. Append-mode files use marker-guarded idempotency.
|
|
360
|
+
* Create-mode files are skipped when they already exist unless `force`
|
|
361
|
+
* is set (we never silently stomp a user's file).
|
|
362
|
+
*
|
|
363
|
+
* @param {string} cwd
|
|
364
|
+
* @param {RenderedPlan} plan
|
|
365
|
+
* @param {{dryRun?:boolean, force?:boolean}} [opts]
|
|
366
|
+
* @returns {Array<{path:string, action:"wrote"|"skipped"|"appended"|"would_write"|"would_skip"|"would_append"}>}
|
|
367
|
+
*/
|
|
368
|
+
export function applyPlan(cwd, plan, opts = {}) {
|
|
369
|
+
const { dryRun = false, force = false } = opts;
|
|
370
|
+
/** @type {Array<{path:string, action:string}>} */
|
|
371
|
+
const results = [];
|
|
372
|
+
|
|
373
|
+
for (const file of plan.files) {
|
|
374
|
+
const abs = path.join(cwd, file.path);
|
|
375
|
+
|
|
376
|
+
if (file.mode === "append") {
|
|
377
|
+
const current = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : "";
|
|
378
|
+
if (current.includes(ENV_EXAMPLE_MARKER_START)) {
|
|
379
|
+
results.push({ path: file.path, action: dryRun ? "would_skip" : "skipped" });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (dryRun) {
|
|
383
|
+
results.push({ path: file.path, action: "would_append" });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
387
|
+
const prefix = current.length && !current.endsWith("\n") ? "\n" : "";
|
|
388
|
+
fs.writeFileSync(abs, current + prefix + file.content, "utf8");
|
|
389
|
+
results.push({ path: file.path, action: "appended" });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (fs.existsSync(abs) && !force) {
|
|
394
|
+
results.push({ path: file.path, action: dryRun ? "would_skip" : "skipped" });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (dryRun) {
|
|
398
|
+
results.push({ path: file.path, action: "would_write" });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
402
|
+
fs.writeFileSync(abs, file.content, "utf8");
|
|
403
|
+
results.push({ path: file.path, action: "wrote" });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return results;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Top-level entry point used by `shipctl init --bootstrap`.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} cwd
|
|
413
|
+
* @param {object} config
|
|
414
|
+
* @param {object|null} presetArtifact Reserved for v2 when we parse the preset body.
|
|
415
|
+
* @param {Array<object>} _adapters Reserved for v2.
|
|
416
|
+
* @param {{dryRun?:boolean, force?:boolean}} [opts]
|
|
417
|
+
*/
|
|
418
|
+
export function renderBootstrap(cwd, config, presetArtifact, _adapters, opts = {}) {
|
|
419
|
+
const plan = renderPlan(config, presetArtifact);
|
|
420
|
+
const results = applyPlan(cwd, plan, opts);
|
|
421
|
+
return { plan, results };
|
|
422
|
+
}
|