@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
@@ -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
+ }