@elmundi/ship-cli 0.8.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +415 -22
  2. package/bin/shipctl.mjs +165 -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 +373 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +302 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +1 -1
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +96 -21
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +368 -0
  42. package/lib/commands/lanes.mjs +502 -0
  43. package/lib/commands/manifest-catalog.mjs +102 -38
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +9 -43
  47. package/lib/commands/run.mjs +617 -0
  48. package/lib/commands/sync.mjs +749 -0
  49. package/lib/commands/telemetry.mjs +390 -0
  50. package/lib/commands/verify.mjs +187 -0
  51. package/lib/config/io.mjs +232 -0
  52. package/lib/config/migrate.mjs +215 -0
  53. package/lib/config/schema.mjs +650 -0
  54. package/lib/detect.mjs +162 -19
  55. package/lib/feedback/drafts.mjs +129 -0
  56. package/lib/find-ship-root.mjs +16 -10
  57. package/lib/http.mjs +237 -11
  58. package/lib/state/idempotency.mjs +183 -0
  59. package/lib/state/lockfile.mjs +180 -0
  60. package/lib/telemetry/outbox.mjs +224 -0
  61. package/lib/templates.mjs +53 -65
  62. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  63. package/lib/verify/checks/api-reachable.mjs +39 -0
  64. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  65. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  66. package/lib/verify/checks/cache-integrity.mjs +51 -0
  67. package/lib/verify/checks/ci-secrets.mjs +86 -0
  68. package/lib/verify/checks/config-present.mjs +39 -0
  69. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  70. package/lib/verify/checks/rules-markers.mjs +135 -0
  71. package/lib/verify/checks/stack-enums.mjs +33 -0
  72. package/lib/verify/checks/tracker-labels.mjs +91 -0
  73. package/lib/verify/registry.mjs +120 -0
  74. package/lib/version.mjs +34 -0
  75. package/package.json +10 -3
  76. package/bin/ship.mjs +0 -68
@@ -0,0 +1,232 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import YAML from "yaml";
5
+ import { DEFAULT_CONFIG } from "./schema.mjs";
6
+
7
+ export const SHIP_DIR = ".ship";
8
+ export const CONFIG_REL = path.join(SHIP_DIR, "config.yml");
9
+ export const STATE_REL = path.join(SHIP_DIR, "state.json");
10
+
11
+ /**
12
+ * Walk upward from startCwd looking for `.ship/config.yml`.
13
+ * Returns the directory containing `.ship/` or null.
14
+ * @param {string} startCwd
15
+ * @returns {string | null}
16
+ */
17
+ export function findShipRoot(startCwd) {
18
+ let dir = path.resolve(startCwd || process.cwd());
19
+ for (;;) {
20
+ if (fs.existsSync(path.join(dir, CONFIG_REL))) return dir;
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) return null;
23
+ dir = parent;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Stable top-level and nested key order. Unknown keys are appended alphabetically.
29
+ *
30
+ * v2 introduces `agent` and `lanes` at the top level, plus nested keys
31
+ * under each lane. We keep them in their "natural reading order" so a
32
+ * human diffing two configs doesn't see a churn just because `shipctl
33
+ * config set` rewrote the file.
34
+ */
35
+ const KEY_ORDER = {
36
+ __root: [
37
+ "version",
38
+ "shipctl_min",
39
+ "api",
40
+ "stack",
41
+ "agent",
42
+ "lanes",
43
+ "artifacts",
44
+ "cache",
45
+ "telemetry",
46
+ ],
47
+ api: ["base_url", "channel", "ttl_hours", "offline_ok"],
48
+ stack: ["tracker", "ci", "agents", "agent", "language", "preset"],
49
+ "stack.agent": ["provider"],
50
+ agent: ["default", "overrides"],
51
+ "agent.default": ["provider"],
52
+ /* lanes.* is handled by LANE_KEY_ORDER below — each lane follows the
53
+ * same ordering regardless of its id. */
54
+ artifacts: ["pins", "auto_update"],
55
+ cache: ["vcs_tracked"],
56
+ telemetry: ["share", "anonymous_id", "scope"],
57
+ "telemetry.scope": ["artifact_usage", "improvement_drafts", "errors"],
58
+ };
59
+
60
+ const LANE_KEY_ORDER = [
61
+ "kind",
62
+ "pattern",
63
+ "pattern_version",
64
+ "on",
65
+ "when",
66
+ "cron",
67
+ "cron_tz",
68
+ "idempotency",
69
+ "permissions",
70
+ "runner",
71
+ "timeout_minutes",
72
+ "concurrency",
73
+ ];
74
+
75
+ const LANE_IDEMPOTENCY_KEY_ORDER = ["key", "store", "reset_on"];
76
+
77
+ function orderForPath(pathKey) {
78
+ /* lanes.<anything> — single lane entry, always share the same order */
79
+ if (/^lanes\.[^.]+$/.test(pathKey)) return LANE_KEY_ORDER;
80
+ if (/^lanes\.[^.]+\.idempotency$/.test(pathKey)) return LANE_IDEMPOTENCY_KEY_ORDER;
81
+ return KEY_ORDER[pathKey] || [];
82
+ }
83
+
84
+ const USER_KEYED_LEAF_MAPS = new Set(["artifacts.pins", "agent.overrides"]);
85
+ const USER_KEYED_STRUCT_MAPS = new Set(["lanes"]);
86
+
87
+ function orderedCopy(obj, pathKey) {
88
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
89
+
90
+ /* User-keyed leaf map: keep author's key order, don't recurse (values
91
+ * are simple scalars like a semver string). */
92
+ if (USER_KEYED_LEAF_MAPS.has(pathKey)) {
93
+ const out = {};
94
+ for (const k of Object.keys(obj)) out[k] = obj[k];
95
+ return out;
96
+ }
97
+
98
+ /* User-keyed struct map: keep author's key order, but recurse into
99
+ * each entry so its internal fields get normalised. */
100
+ if (USER_KEYED_STRUCT_MAPS.has(pathKey)) {
101
+ const out = {};
102
+ for (const k of Object.keys(obj)) {
103
+ out[k] = orderedCopy(obj[k], `${pathKey}.${k}`);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ const order = orderForPath(pathKey);
109
+ const remaining = new Set(Object.keys(obj));
110
+ const out = {};
111
+ for (const k of order) {
112
+ if (remaining.has(k)) {
113
+ out[k] = obj[k];
114
+ remaining.delete(k);
115
+ }
116
+ }
117
+ for (const k of [...remaining].sort()) out[k] = obj[k];
118
+
119
+ for (const [k, v] of Object.entries(out)) {
120
+ const childKey = pathKey === "__root" ? k : pathKey ? `${pathKey}.${k}` : k;
121
+ out[k] = orderedCopy(v, childKey);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * @param {string} cwd
128
+ * @returns {{config:object, filePath:string}}
129
+ */
130
+ export function readConfig(cwd) {
131
+ const root = findShipRoot(cwd);
132
+ if (!root) {
133
+ throw new Error(
134
+ `.ship/config.yml not found (searched from ${path.resolve(cwd || process.cwd())} upward). Run 'shipctl config init' first.`,
135
+ );
136
+ }
137
+ const filePath = path.join(root, CONFIG_REL);
138
+ let text;
139
+ try {
140
+ text = fs.readFileSync(filePath, "utf8");
141
+ } catch (e) {
142
+ throw new Error(`Failed to read ${filePath}: ${e.message}`);
143
+ }
144
+ let parsed;
145
+ try {
146
+ parsed = YAML.parse(text);
147
+ } catch (e) {
148
+ throw new Error(`Failed to parse ${filePath}: ${e.message}`);
149
+ }
150
+ if (!parsed || typeof parsed !== "object") {
151
+ throw new Error(`${filePath}: top-level must be a YAML mapping`);
152
+ }
153
+ return { config: parsed, filePath };
154
+ }
155
+
156
+ /**
157
+ * @param {string} filePath
158
+ * @param {object} config
159
+ */
160
+ export function writeConfig(filePath, config) {
161
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
162
+ const ordered = orderedCopy(JSON.parse(JSON.stringify(config)), "__root");
163
+ const body = YAML.stringify(ordered, {
164
+ lineWidth: 0,
165
+ indent: 2,
166
+ defaultStringType: "PLAIN",
167
+ });
168
+ const tmp = `${filePath}.tmp`;
169
+ fs.writeFileSync(tmp, body, "utf8");
170
+ fs.renameSync(tmp, filePath);
171
+ }
172
+
173
+ /**
174
+ * Generate a fresh UUID v4 into config.telemetry.anonymous_id if missing/invalid.
175
+ * Mutates the config in place.
176
+ * @param {object} config
177
+ * @returns {string} the resulting anonymous_id
178
+ */
179
+ export function ensureAnonymousId(config) {
180
+ if (!config.telemetry || typeof config.telemetry !== "object") config.telemetry = {};
181
+ const cur = config.telemetry.anonymous_id;
182
+ const valid =
183
+ typeof cur === "string" &&
184
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(cur);
185
+ if (!valid) config.telemetry.anonymous_id = randomUUID();
186
+ return config.telemetry.anonymous_id;
187
+ }
188
+
189
+ /**
190
+ * Default empty state for .ship/state.json.
191
+ */
192
+ export function defaultState() {
193
+ return {
194
+ last_sync_at: null,
195
+ last_manifest_hash: null,
196
+ outbox_pending_count: 0,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * @param {string} cwd
202
+ * @returns {{state:object, filePath:string}}
203
+ */
204
+ export function readState(cwd) {
205
+ const root = findShipRoot(cwd);
206
+ if (!root) throw new Error(".ship/ not found; run 'shipctl config init' first.");
207
+ const filePath = path.join(root, STATE_REL);
208
+ if (!fs.existsSync(filePath)) return { state: defaultState(), filePath };
209
+ try {
210
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
211
+ return { state: { ...defaultState(), ...(parsed || {}) }, filePath };
212
+ } catch (e) {
213
+ throw new Error(`Failed to parse ${filePath}: ${e.message}`);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * @param {string} cwd
219
+ * @param {object} state
220
+ */
221
+ export function writeState(cwd, state) {
222
+ const root = findShipRoot(cwd);
223
+ if (!root) throw new Error(".ship/ not found; run 'shipctl config init' first.");
224
+ const filePath = path.join(root, STATE_REL);
225
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
226
+ const tmp = `${filePath}.tmp`;
227
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
228
+ fs.renameSync(tmp, filePath);
229
+ return filePath;
230
+ }
231
+
232
+ export { DEFAULT_CONFIG };
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `shipctl migrate` — convert `.ship/config.yml` from v1 to v2.
3
+ *
4
+ * v2 introduces the `lanes` map, `agent.default/overrides`, and deprecates
5
+ * the `workflow` artifact kind. The migration is deliberately conservative:
6
+ *
7
+ * - Every v1 field we still recognise is copied verbatim, not rewritten.
8
+ * - `stack.agent.provider` is lifted into `agent.default.provider`; we
9
+ * leave `stack.agent` intact so rolling back to an old shipctl still
10
+ * finds the field where v1 expected it.
11
+ * - The legacy `lanes:` list-of-strings (a shape customers wrote by
12
+ * hand between 0.9.x and 0.11.x — not a formal v1 field but widely
13
+ * present on disk) is translated into the v2 `lanes:` map using the
14
+ * preset defaults table below.
15
+ * - Unknown keys survive at the top level; v2 validation emits a
16
+ * warning but does not drop them.
17
+ *
18
+ * The migration is idempotent: running it against a v2 config returns
19
+ * the input untouched.
20
+ */
21
+
22
+ import { CONFIG_SCHEMA_VERSION, LEGACY_CONFIG_SCHEMA_VERSION } from "./schema.mjs";
23
+
24
+ /**
25
+ * Default lane translations for the well-known v1 `lanes:` list entries.
26
+ * Each entry maps the v1 string id to a v2 lane body. These were the
27
+ * four lane ids the `monorepo`, `web-app`, and `api-backend` presets
28
+ * bundled; anything outside this table is left for the caller to fill
29
+ * in manually (the migrator warns and adds a stub).
30
+ *
31
+ * Keep these aligned with `artifacts/collections/preset-*` and with
32
+ * RFC-0007 §"Lane-id reservations".
33
+ */
34
+ const V1_LANE_DEFAULTS = Object.freeze({
35
+ pr_review: {
36
+ kind: "event",
37
+ pattern: "catalog-a5-pr-self-review",
38
+ on: "pull_request",
39
+ permissions: { contents: "read", "pull-requests": "write" },
40
+ },
41
+ daily_standup: {
42
+ kind: "schedule",
43
+ pattern: "catalog-a13-daily-retro",
44
+ cron: "0 9 * * 1-5",
45
+ },
46
+ tech_debt: {
47
+ kind: "schedule",
48
+ pattern: "catalog-a12-learning",
49
+ cron: "0 10 * * 1",
50
+ },
51
+ self_heal: {
52
+ kind: "event",
53
+ pattern: "cloud-workflow-self-heal",
54
+ on: "workflow_run",
55
+ when: { conclusion: "failure" },
56
+ permissions: { contents: "read", actions: "read", "pull-requests": "write" },
57
+ },
58
+ });
59
+
60
+ /**
61
+ * @typedef {{
62
+ * migrated: boolean,
63
+ * config: object,
64
+ * warnings: string[],
65
+ * stub_lanes: string[],
66
+ * }} MigrationResult
67
+ */
68
+
69
+ /**
70
+ * Migrate a parsed config object from v1 to v2. Returns a fresh config
71
+ * object (input is not mutated) plus any non-fatal warnings.
72
+ *
73
+ * @param {object} input
74
+ * @returns {MigrationResult}
75
+ */
76
+ export function migrateV1ToV2(input) {
77
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
78
+ throw new Error("migrate: config must be a mapping");
79
+ }
80
+
81
+ if (input.version === CONFIG_SCHEMA_VERSION) {
82
+ return {
83
+ migrated: false,
84
+ config: input,
85
+ warnings: ["config already at v2; nothing to do"],
86
+ stub_lanes: [],
87
+ };
88
+ }
89
+ if (input.version !== LEGACY_CONFIG_SCHEMA_VERSION) {
90
+ throw new Error(
91
+ `migrate: unsupported source version ${JSON.stringify(input.version)}; only v${LEGACY_CONFIG_SCHEMA_VERSION} is supported`,
92
+ );
93
+ }
94
+
95
+ /* Deep-clone first — never touch the caller's object. YAML parse
96
+ * output is pure JSON so JSON round-trip is safe. */
97
+ const src = JSON.parse(JSON.stringify(input));
98
+ const warnings = [];
99
+ const stubLanes = [];
100
+
101
+ const out = {
102
+ version: CONFIG_SCHEMA_VERSION,
103
+ /* Bump the hard floor to the release that introduces v2. Anyone
104
+ * stuck on <0.12 will fail loudly on read instead of silently
105
+ * pretending v2 is v1. */
106
+ shipctl_min: bumpFloor(src.shipctl_min, "0.12.0"),
107
+ };
108
+
109
+ /* api / stack / cache / telemetry / artifacts survive verbatim — v2
110
+ * only added new top-level siblings. Preserve unknown keys too so a
111
+ * future field added by a newer shipctl on the same config doesn't
112
+ * get eaten by an older migrator. */
113
+ for (const k of Object.keys(src)) {
114
+ if (k === "version" || k === "shipctl_min") continue;
115
+ if (k === "lanes") continue; /* handled below */
116
+ out[k] = src[k];
117
+ }
118
+
119
+ /* agent.default / agent.overrides */
120
+ out.agent = out.agent && typeof out.agent === "object" ? out.agent : {};
121
+ if (!out.agent.default || typeof out.agent.default !== "object") {
122
+ out.agent.default = { provider: null };
123
+ }
124
+ if (!out.agent.overrides || typeof out.agent.overrides !== "object") {
125
+ out.agent.overrides = {};
126
+ }
127
+ /* Lift stack.agent.provider into agent.default.provider if unset.
128
+ * We intentionally leave the original value in place so v1 readers
129
+ * keep working; v2 readers prefer agent.default.provider anyway. */
130
+ const liftedProvider = src.stack?.agent?.provider ?? null;
131
+ if (liftedProvider && !out.agent.default.provider) {
132
+ out.agent.default.provider = liftedProvider;
133
+ }
134
+
135
+ /* lanes: translate from the legacy list-of-strings shape. */
136
+ out.lanes = {};
137
+ const srcLanes = src.lanes;
138
+ if (Array.isArray(srcLanes)) {
139
+ for (const laneId of srcLanes) {
140
+ if (typeof laneId !== "string") {
141
+ warnings.push(`lanes: skipped non-string entry ${JSON.stringify(laneId)}`);
142
+ continue;
143
+ }
144
+ const normalised = laneId.trim();
145
+ if (!normalised) continue;
146
+ const def = V1_LANE_DEFAULTS[normalised];
147
+ if (def) {
148
+ out.lanes[normalised] = cloneDefault(def);
149
+ } else {
150
+ /* Unknown v1 lane — emit a stub so the customer sees exactly
151
+ * which fields need attention on the next `shipctl doctor`. */
152
+ out.lanes[normalised] = {
153
+ kind: "schedule",
154
+ pattern: `TODO-pattern-for-${normalised}`,
155
+ cron: "TODO",
156
+ };
157
+ stubLanes.push(normalised);
158
+ warnings.push(
159
+ `lanes.${normalised}: no preset mapping; wrote a stub (fill in kind/pattern/cron before shipping)`,
160
+ );
161
+ }
162
+ }
163
+ } else if (srcLanes && typeof srcLanes === "object") {
164
+ /* Already a map (e.g. someone hand-edited partway). Copy as-is;
165
+ * the v2 validator will flag any malformed lanes on the next
166
+ * `shipctl doctor` or write. */
167
+ out.lanes = JSON.parse(JSON.stringify(srcLanes));
168
+ } else if (srcLanes !== undefined) {
169
+ warnings.push(
170
+ `lanes: unexpected v1 shape ${typeof srcLanes}; dropped. Add lanes manually or rerun 'shipctl init'.`,
171
+ );
172
+ }
173
+
174
+ return {
175
+ migrated: true,
176
+ config: out,
177
+ warnings,
178
+ stub_lanes: stubLanes,
179
+ };
180
+ }
181
+
182
+ function cloneDefault(laneBody) {
183
+ return JSON.parse(JSON.stringify(laneBody));
184
+ }
185
+
186
+ /**
187
+ * Parse a semver-ish "X.Y.Z" and return the higher of the current floor
188
+ * and the minimum the caller wants. If `current` is not a string or
189
+ * doesn't parse, we fall back to the minimum unconditionally — an
190
+ * unreadable floor is no floor.
191
+ *
192
+ * @param {unknown} current
193
+ * @param {string} minimum
194
+ * @returns {string}
195
+ */
196
+ function bumpFloor(current, minimum) {
197
+ if (typeof current !== "string") return minimum;
198
+ const cur = parseSemver(current);
199
+ const min = parseSemver(minimum);
200
+ if (!cur || !min) return minimum;
201
+ return compareSemver(cur, min) >= 0 ? current : minimum;
202
+ }
203
+
204
+ function parseSemver(s) {
205
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(s).trim());
206
+ if (!m) return null;
207
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
208
+ }
209
+
210
+ function compareSemver(a, b) {
211
+ for (let i = 0; i < 3; i += 1) {
212
+ if (a[i] !== b[i]) return a[i] - b[i];
213
+ }
214
+ return 0;
215
+ }