@elmundi/ship-cli 0.8.0 → 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.
- package/README.md +456 -32
- package/bin/shipctl.mjs +165 -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 +373 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +302 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +1 -1
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +96 -21
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +368 -0
- package/lib/commands/lanes.mjs +502 -0
- package/lib/commands/manifest-catalog.mjs +102 -38
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +9 -43
- package/lib/commands/run.mjs +617 -0
- package/lib/commands/sync.mjs +749 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +215 -0
- package/lib/config/schema.mjs +650 -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,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipctl callback` — report the terminal status of a pipeline run back
|
|
3
|
+
* to Ship.
|
|
4
|
+
*
|
|
5
|
+
* The customer's GitHub Actions workflow runs this in an `if: always()`
|
|
6
|
+
* step at the end of the job. It replaces the 12-line `curl + HEREDOC`
|
|
7
|
+
* boilerplate the previous starter workflows shipped with, so adopters
|
|
8
|
+
* get a one-liner and a versioned CLI instead of hand-rolled JSON that
|
|
9
|
+
* has silently broken every time Ship evolves the callback contract.
|
|
10
|
+
*
|
|
11
|
+
* URL resolution (first hit wins):
|
|
12
|
+
* 1. `--callback-url <url>` flag
|
|
13
|
+
* 2. `SHIP_CALLBACK_URL` env (what the existing workflow.yml injects)
|
|
14
|
+
* 3. `--base-url <https://api.ship.example.com>` + `--run-id <uuid>`
|
|
15
|
+
* (or `SHIP_API_BASE` + `SHIP_RUN_ID` envs) → constructed as
|
|
16
|
+
* `{base}/v1/pipelines/runs/{run_id}/result`.
|
|
17
|
+
*
|
|
18
|
+
* Auth: exclusively the bearer token minted by Ship at dispatch time.
|
|
19
|
+
* - Required env: `SHIP_RUN_TOKEN`. We refuse to fall back to
|
|
20
|
+
* `SHIP_API_TOKEN` (the long-lived operator token used elsewhere in
|
|
21
|
+
* this CLI) because a workflow-context callback must *only* use the
|
|
22
|
+
* short-lived, run-scoped JWT. Cross-auth would silently hide bugs.
|
|
23
|
+
*
|
|
24
|
+
* This is intentionally **not** mounted under the `base-url` global flag
|
|
25
|
+
* (which defaults to the public methodology host); run callbacks hit the
|
|
26
|
+
* orchestration API (`api.ship.elmundi.com`), a different origin, so we
|
|
27
|
+
* take the URL directly from the run context Ship injected.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** @typedef {"succeeded"|"failed"|"cancelled"} TerminalStatus */
|
|
31
|
+
|
|
32
|
+
const STATUS_ALIASES = {
|
|
33
|
+
ok: "succeeded",
|
|
34
|
+
succeeded: "succeeded",
|
|
35
|
+
success: "succeeded",
|
|
36
|
+
pass: "succeeded",
|
|
37
|
+
green: "succeeded",
|
|
38
|
+
fail: "failed",
|
|
39
|
+
failed: "failed",
|
|
40
|
+
failure: "failed",
|
|
41
|
+
red: "failed",
|
|
42
|
+
cancelled: "cancelled",
|
|
43
|
+
canceled: "cancelled",
|
|
44
|
+
cancel: "cancelled",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const EXIT_USAGE = 2;
|
|
48
|
+
const EXIT_AUTH = 10;
|
|
49
|
+
const EXIT_CONFIG = 11;
|
|
50
|
+
const EXIT_HTTP = 3;
|
|
51
|
+
|
|
52
|
+
function die(code, msg) {
|
|
53
|
+
console.error(msg);
|
|
54
|
+
process.exit(code);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printCallbackHelp() {
|
|
58
|
+
console.log(`shipctl callback — report a pipeline run's terminal status to Ship.
|
|
59
|
+
|
|
60
|
+
USAGE
|
|
61
|
+
shipctl callback --status <ok|fail|cancelled> [--summary "..."] [--metric k=v]...
|
|
62
|
+
|
|
63
|
+
FLAGS
|
|
64
|
+
--status Terminal status. Aliases: ok|success|succeeded, fail|failed,
|
|
65
|
+
cancelled|canceled. Required.
|
|
66
|
+
--summary One-line human summary (≤1024 chars). Optional.
|
|
67
|
+
--metric k=v Structured metric to attach. Repeatable. Values coerced:
|
|
68
|
+
numbers, booleans (true|false), JSON (prefix { or [), else string.
|
|
69
|
+
Example: --metric tickets_processed=3 --metric dry_run=true
|
|
70
|
+
--run-id Pipeline run UUID (usually set by SHIP_RUN_ID env).
|
|
71
|
+
--callback-url Full callback URL (usually set by SHIP_CALLBACK_URL env).
|
|
72
|
+
--base-url Orchestration API base (default: SHIP_API_BASE env). Combined
|
|
73
|
+
with --run-id to construct the URL when --callback-url absent.
|
|
74
|
+
--json Print the Ship response JSON on success.
|
|
75
|
+
--help Show this help.
|
|
76
|
+
|
|
77
|
+
ENV
|
|
78
|
+
SHIP_RUN_TOKEN (required) Short-lived bearer Ship issued for this run.
|
|
79
|
+
SHIP_CALLBACK_URL (preferred) Full URL of the result endpoint.
|
|
80
|
+
SHIP_RUN_ID Fallback input for --run-id.
|
|
81
|
+
SHIP_API_BASE Fallback input for --base-url.
|
|
82
|
+
|
|
83
|
+
EXAMPLE (inside a workflow.yml ‹if: always()› step)
|
|
84
|
+
shipctl callback --status ok \\
|
|
85
|
+
--summary "Intake processed TICKET-42" \\
|
|
86
|
+
--metric tickets_processed=1 \\
|
|
87
|
+
--metric ticket_ids=LIN-42
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Parse --metric k=v pairs with sensible coercion. We deliberately keep
|
|
92
|
+
* this small — Ship's callback ``metrics`` blob is a free-form JSON bag,
|
|
93
|
+
* so the CLI should offer the common shorthand (numbers, booleans, JSON
|
|
94
|
+
* literals) without growing a tiny DSL. Strings are the fallback. */
|
|
95
|
+
function coerceMetricValue(raw) {
|
|
96
|
+
if (raw === "") return "";
|
|
97
|
+
if (raw === "true") return true;
|
|
98
|
+
if (raw === "false") return false;
|
|
99
|
+
if (raw === "null") return null;
|
|
100
|
+
if (/^-?\d+$/.test(raw)) {
|
|
101
|
+
const n = Number(raw);
|
|
102
|
+
if (Number.isSafeInteger(n)) return n;
|
|
103
|
+
}
|
|
104
|
+
if (/^-?\d+\.\d+$/.test(raw)) {
|
|
105
|
+
const n = Number(raw);
|
|
106
|
+
if (Number.isFinite(n)) return n;
|
|
107
|
+
}
|
|
108
|
+
const first = raw[0];
|
|
109
|
+
if (first === "{" || first === "[") {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
/* fall through to string */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return raw;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseMetricArg(tok) {
|
|
120
|
+
const eq = tok.indexOf("=");
|
|
121
|
+
if (eq <= 0) {
|
|
122
|
+
die(EXIT_USAGE, `--metric expects key=value; got: ${tok}`);
|
|
123
|
+
}
|
|
124
|
+
const key = tok.slice(0, eq).trim();
|
|
125
|
+
const value = tok.slice(eq + 1);
|
|
126
|
+
if (!key) die(EXIT_USAGE, `--metric key cannot be empty: ${tok}`);
|
|
127
|
+
return { key, value: coerceMetricValue(value) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseCallbackArgs(rest) {
|
|
131
|
+
const out = {
|
|
132
|
+
status: null,
|
|
133
|
+
summary: null,
|
|
134
|
+
metrics: {},
|
|
135
|
+
runId: null,
|
|
136
|
+
callbackUrl: null,
|
|
137
|
+
baseUrl: null,
|
|
138
|
+
json: false,
|
|
139
|
+
help: false,
|
|
140
|
+
};
|
|
141
|
+
const copy = [...rest];
|
|
142
|
+
/* Tiny arg-munger kept inline rather than pulling a dependency —
|
|
143
|
+
* matches the style of feedback.mjs / patterns.mjs and keeps this CLI
|
|
144
|
+
* zero-prod-deps apart from `yaml`. */
|
|
145
|
+
const strFlag = (name, key) => {
|
|
146
|
+
if (copy[0] === name && copy[1] !== undefined) {
|
|
147
|
+
copy.shift();
|
|
148
|
+
out[key] = String(copy.shift());
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const p = `${name}=`;
|
|
152
|
+
if (copy[0] && copy[0].startsWith(p)) {
|
|
153
|
+
out[key] = copy[0].slice(p.length);
|
|
154
|
+
copy.shift();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
};
|
|
159
|
+
while (copy.length) {
|
|
160
|
+
const a = copy[0];
|
|
161
|
+
if (a === "--help" || a === "-h") {
|
|
162
|
+
out.help = true;
|
|
163
|
+
copy.shift();
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (a === "--json") {
|
|
167
|
+
out.json = true;
|
|
168
|
+
copy.shift();
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (strFlag("--status", "status")) continue;
|
|
172
|
+
if (strFlag("--summary", "summary")) continue;
|
|
173
|
+
if (strFlag("--run-id", "runId")) continue;
|
|
174
|
+
if (strFlag("--callback-url", "callbackUrl")) continue;
|
|
175
|
+
if (strFlag("--base-url", "baseUrl")) continue;
|
|
176
|
+
if (a === "--metric" && copy[1] !== undefined) {
|
|
177
|
+
copy.shift();
|
|
178
|
+
const { key, value } = parseMetricArg(String(copy.shift()));
|
|
179
|
+
out.metrics[key] = value;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (a && a.startsWith("--metric=")) {
|
|
183
|
+
const raw = a.slice("--metric=".length);
|
|
184
|
+
copy.shift();
|
|
185
|
+
const { key, value } = parseMetricArg(raw);
|
|
186
|
+
out.metrics[key] = value;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl callback --help`);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function normaliseStatus(raw) {
|
|
195
|
+
if (!raw) return null;
|
|
196
|
+
const lower = String(raw).toLowerCase().trim();
|
|
197
|
+
return STATUS_ALIASES[lower] ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function resolveCallbackUrl(args, env = process.env) {
|
|
201
|
+
if (args.callbackUrl) return args.callbackUrl;
|
|
202
|
+
if (env.SHIP_CALLBACK_URL) return env.SHIP_CALLBACK_URL;
|
|
203
|
+
const runId = args.runId || env.SHIP_RUN_ID || null;
|
|
204
|
+
const base = args.baseUrl || env.SHIP_API_BASE || null;
|
|
205
|
+
if (runId && base) {
|
|
206
|
+
return `${base.replace(/\/$/, "")}/v1/pipelines/runs/${runId}/result`;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildCallbackBody(args) {
|
|
212
|
+
/** @type {Record<string, unknown>} */
|
|
213
|
+
const body = { status: args.status };
|
|
214
|
+
if (args.summary) body.summary = String(args.summary).slice(0, 1024);
|
|
215
|
+
if (Object.keys(args.metrics).length > 0) body.metrics = args.metrics;
|
|
216
|
+
return body;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function callbackCommand(_ctx, rest) {
|
|
220
|
+
const args = parseCallbackArgs(rest);
|
|
221
|
+
if (args.help) {
|
|
222
|
+
printCallbackHelp();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const status = normaliseStatus(args.status);
|
|
227
|
+
if (!status) {
|
|
228
|
+
die(
|
|
229
|
+
EXIT_USAGE,
|
|
230
|
+
`--status is required (ok|fail|cancelled). Got: ${args.status ?? "<missing>"}\nRun: shipctl callback --help`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
args.status = status;
|
|
234
|
+
|
|
235
|
+
const token = process.env.SHIP_RUN_TOKEN;
|
|
236
|
+
if (!token) {
|
|
237
|
+
die(
|
|
238
|
+
EXIT_AUTH,
|
|
239
|
+
"SHIP_RUN_TOKEN env var is required. Ship injects it into workflow_dispatch inputs; set it in the callback step's env block.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const url = resolveCallbackUrl(args);
|
|
244
|
+
if (!url) {
|
|
245
|
+
die(
|
|
246
|
+
EXIT_CONFIG,
|
|
247
|
+
"Cannot resolve callback URL. Set SHIP_CALLBACK_URL (preferred — Ship injects it), or pass --callback-url, or combine SHIP_API_BASE + SHIP_RUN_ID.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const body = buildCallbackBody(args);
|
|
252
|
+
|
|
253
|
+
let res;
|
|
254
|
+
try {
|
|
255
|
+
res = await fetch(url, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: {
|
|
258
|
+
"Content-Type": "application/json",
|
|
259
|
+
Accept: "application/json",
|
|
260
|
+
Authorization: `Bearer ${token}`,
|
|
261
|
+
"User-Agent": await getUA(),
|
|
262
|
+
},
|
|
263
|
+
body: JSON.stringify(body),
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
die(EXIT_HTTP, `callback POST failed: ${err instanceof Error ? err.message : err}`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const text = await res.text();
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const hint =
|
|
273
|
+
res.status === 401
|
|
274
|
+
? " (check SHIP_RUN_TOKEN matches the run Ship dispatched)"
|
|
275
|
+
: res.status === 404
|
|
276
|
+
? " (check SHIP_RUN_ID — the run may not exist)"
|
|
277
|
+
: res.status === 422
|
|
278
|
+
? " (check --status is one of succeeded|failed|cancelled)"
|
|
279
|
+
: "";
|
|
280
|
+
die(
|
|
281
|
+
EXIT_HTTP,
|
|
282
|
+
`Ship rejected callback: HTTP ${res.status} ${res.statusText}${hint}\n${text}`,
|
|
283
|
+
);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (args.json) {
|
|
288
|
+
console.log(text);
|
|
289
|
+
} else {
|
|
290
|
+
console.log(`callback accepted: ${status}${args.summary ? ` — ${args.summary}` : ""}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* Lazy import to keep the helper self-contained & testable. */
|
|
295
|
+
async function getUA() {
|
|
296
|
+
try {
|
|
297
|
+
const { getUserAgent } = await import("../version.mjs");
|
|
298
|
+
return getUserAgent();
|
|
299
|
+
} catch {
|
|
300
|
+
return "shipctl-callback";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_CONFIG,
|
|
6
|
+
ensureAnonymousId,
|
|
7
|
+
findShipRoot,
|
|
8
|
+
readConfig,
|
|
9
|
+
writeConfig,
|
|
10
|
+
writeState,
|
|
11
|
+
defaultState,
|
|
12
|
+
SHIP_DIR,
|
|
13
|
+
CONFIG_REL,
|
|
14
|
+
STATE_REL,
|
|
15
|
+
} from "../config/io.mjs";
|
|
16
|
+
import { validateConfig } from "../config/schema.mjs";
|
|
17
|
+
|
|
18
|
+
function parseConfigArgs(rest) {
|
|
19
|
+
const out = { cwd: null, positional: [] };
|
|
20
|
+
const copy = [...rest];
|
|
21
|
+
while (copy.length) {
|
|
22
|
+
const a = copy[0];
|
|
23
|
+
if (a === "--cwd" && copy[1]) {
|
|
24
|
+
copy.shift();
|
|
25
|
+
out.cwd = String(copy.shift());
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (a.startsWith("--cwd=")) {
|
|
29
|
+
out.cwd = a.slice("--cwd=".length);
|
|
30
|
+
copy.shift();
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
out.positional.push(copy.shift());
|
|
34
|
+
}
|
|
35
|
+
out.cwd = out.cwd || process.cwd();
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureGitignoreEntry(shipRoot) {
|
|
40
|
+
const giPath = path.join(shipRoot, ".gitignore");
|
|
41
|
+
const entries = [
|
|
42
|
+
"# Ship",
|
|
43
|
+
".ship/cache/",
|
|
44
|
+
".ship/telemetry-outbox.jsonl",
|
|
45
|
+
".ship/feedback-drafts/",
|
|
46
|
+
".ship/state.json",
|
|
47
|
+
];
|
|
48
|
+
let current = "";
|
|
49
|
+
if (fs.existsSync(giPath)) current = fs.readFileSync(giPath, "utf8");
|
|
50
|
+
const existingLines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
51
|
+
const toAppend = entries.filter((e) => !existingLines.has(e.trim()));
|
|
52
|
+
if (toAppend.length === 0) return { giPath, changed: false };
|
|
53
|
+
const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
54
|
+
const tail = current.length === 0 ? `${toAppend.join("\n")}\n` : `${prefix}${toAppend.join("\n")}\n`;
|
|
55
|
+
fs.writeFileSync(giPath, current + tail, "utf8");
|
|
56
|
+
return { giPath, changed: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function initCmd(rest) {
|
|
60
|
+
const { cwd } = parseConfigArgs(rest);
|
|
61
|
+
const root = path.resolve(cwd);
|
|
62
|
+
const shipDir = path.join(root, SHIP_DIR);
|
|
63
|
+
const filePath = path.join(root, CONFIG_REL);
|
|
64
|
+
|
|
65
|
+
if (fs.existsSync(filePath)) {
|
|
66
|
+
console.error(`exists: ${filePath}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fs.mkdirSync(shipDir, { recursive: true });
|
|
71
|
+
const config = DEFAULT_CONFIG();
|
|
72
|
+
ensureAnonymousId(config);
|
|
73
|
+
|
|
74
|
+
writeConfig(filePath, config);
|
|
75
|
+
writeState(root, defaultState());
|
|
76
|
+
|
|
77
|
+
const cacheDir = path.join(shipDir, "cache");
|
|
78
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
79
|
+
const keep = path.join(cacheDir, ".gitkeep");
|
|
80
|
+
if (!fs.existsSync(keep)) fs.writeFileSync(keep, "", "utf8");
|
|
81
|
+
|
|
82
|
+
const { giPath, changed } = ensureGitignoreEntry(root);
|
|
83
|
+
|
|
84
|
+
console.log(`created: ${filePath}`);
|
|
85
|
+
console.log(`created: ${path.join(root, STATE_REL)}`);
|
|
86
|
+
console.log(`created: ${cacheDir}/`);
|
|
87
|
+
console.log(`${changed ? "updated" : "ok "}: ${giPath}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getAtPath(obj, dottedKey) {
|
|
91
|
+
const parts = parsePath(dottedKey);
|
|
92
|
+
let cur = obj;
|
|
93
|
+
for (const p of parts) {
|
|
94
|
+
if (cur == null) return undefined;
|
|
95
|
+
if (typeof cur !== "object") return undefined;
|
|
96
|
+
cur = cur[p];
|
|
97
|
+
}
|
|
98
|
+
return cur;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Split a dotted key, preserving `<kind>/<id>` segments under artifacts.pins.
|
|
103
|
+
* Example: artifacts.pins.pattern/cloud-developer → ["artifacts","pins","pattern/cloud-developer"]
|
|
104
|
+
*/
|
|
105
|
+
function parsePath(dottedKey) {
|
|
106
|
+
const raw = dottedKey.split(".");
|
|
107
|
+
const out = [];
|
|
108
|
+
for (let i = 0; i < raw.length; i++) {
|
|
109
|
+
if (
|
|
110
|
+
out.length === 2 &&
|
|
111
|
+
out[0] === "artifacts" &&
|
|
112
|
+
out[1] === "pins"
|
|
113
|
+
) {
|
|
114
|
+
out.push(raw.slice(i).join("."));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
out.push(raw[i]);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setAtPath(obj, dottedKey, value) {
|
|
123
|
+
const parts = parsePath(dottedKey);
|
|
124
|
+
let cur = obj;
|
|
125
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
126
|
+
const p = parts[i];
|
|
127
|
+
if (cur[p] == null || typeof cur[p] !== "object") cur[p] = {};
|
|
128
|
+
cur = cur[p];
|
|
129
|
+
}
|
|
130
|
+
cur[parts[parts.length - 1]] = value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseValue(raw) {
|
|
134
|
+
if (raw === "true") return true;
|
|
135
|
+
if (raw === "false") return false;
|
|
136
|
+
if (raw === "null") return null;
|
|
137
|
+
if (/^-?\d+$/.test(raw)) return Number(raw);
|
|
138
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number(raw);
|
|
139
|
+
const trimmed = raw.trim();
|
|
140
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
141
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
142
|
+
if (inner.length === 0) return [];
|
|
143
|
+
return inner.split(",").map((x) => parseValue(x.trim().replace(/^['"]|['"]$/g, "")));
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
147
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
148
|
+
) {
|
|
149
|
+
return trimmed.slice(1, -1);
|
|
150
|
+
}
|
|
151
|
+
return raw;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getCmd(rest) {
|
|
155
|
+
const { cwd, positional } = parseConfigArgs(rest);
|
|
156
|
+
const key = positional[0];
|
|
157
|
+
if (!key) {
|
|
158
|
+
console.error("usage: shipctl config get <dotted.key>");
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
const { config } = readConfig(cwd);
|
|
162
|
+
const val = getAtPath(config, key);
|
|
163
|
+
if (val === undefined) {
|
|
164
|
+
console.error(`unknown key: ${key}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(val) || (val !== null && typeof val === "object")) {
|
|
168
|
+
console.log(JSON.stringify(val));
|
|
169
|
+
} else {
|
|
170
|
+
console.log(String(val));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setCmd(rest) {
|
|
175
|
+
const { cwd, positional } = parseConfigArgs(rest);
|
|
176
|
+
const [key, ...valueParts] = positional;
|
|
177
|
+
if (!key || valueParts.length === 0) {
|
|
178
|
+
console.error("usage: shipctl config set <dotted.key> <value>");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const raw = valueParts.join(" ");
|
|
182
|
+
const value = parseValue(raw);
|
|
183
|
+
const { config, filePath } = readConfig(cwd);
|
|
184
|
+
setAtPath(config, key, value);
|
|
185
|
+
|
|
186
|
+
const res = validateConfig(config);
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
for (const e of res.errors) console.error(e);
|
|
189
|
+
process.exit(10);
|
|
190
|
+
}
|
|
191
|
+
for (const w of res.warnings) console.error(`warn: ${w}`);
|
|
192
|
+
|
|
193
|
+
writeConfig(filePath, config);
|
|
194
|
+
console.log(`${key} = ${JSON.stringify(value)}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateCmd(rest) {
|
|
198
|
+
const { cwd } = parseConfigArgs(rest);
|
|
199
|
+
const { config, filePath } = readConfig(cwd);
|
|
200
|
+
const res = validateConfig(config);
|
|
201
|
+
for (const w of res.warnings) console.error(`warn: ${w}`);
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
for (const e of res.errors) console.error(e);
|
|
204
|
+
process.exit(10);
|
|
205
|
+
}
|
|
206
|
+
console.log(`ok: ${filePath}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function showCmd(rest) {
|
|
210
|
+
const { cwd } = parseConfigArgs(rest);
|
|
211
|
+
const { config } = readConfig(cwd);
|
|
212
|
+
process.stdout.write(YAML.stringify(config, { lineWidth: 0, indent: 2 }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pathCmd(rest) {
|
|
216
|
+
const { cwd } = parseConfigArgs(rest);
|
|
217
|
+
const root = findShipRoot(cwd);
|
|
218
|
+
if (!root) {
|
|
219
|
+
console.log("not found");
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
console.log(path.join(root, CONFIG_REL));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function configCommand(_ctx, rest) {
|
|
226
|
+
const [sub, ...tail] = rest;
|
|
227
|
+
if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
|
|
228
|
+
console.log(`shipctl config <subcommand>
|
|
229
|
+
|
|
230
|
+
Subcommands:
|
|
231
|
+
init [--cwd DIR] Create .ship/config.yml + state.json + cache/.
|
|
232
|
+
get <dotted.key> Print value.
|
|
233
|
+
set <dotted.key> <value> Update value (validates; atomic write).
|
|
234
|
+
validate Validate .ship/config.yml; exit 10 on errors.
|
|
235
|
+
show Pretty-print effective YAML.
|
|
236
|
+
path Print absolute path to config file.
|
|
237
|
+
`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
switch (sub) {
|
|
241
|
+
case "init":
|
|
242
|
+
return initCmd(tail);
|
|
243
|
+
case "get":
|
|
244
|
+
return getCmd(tail);
|
|
245
|
+
case "set":
|
|
246
|
+
return setCmd(tail);
|
|
247
|
+
case "validate":
|
|
248
|
+
return validateCmd(tail);
|
|
249
|
+
case "show":
|
|
250
|
+
return showCmd(tail);
|
|
251
|
+
case "path":
|
|
252
|
+
return pathCmd(tail);
|
|
253
|
+
default:
|
|
254
|
+
console.error(`unknown subcommand: config ${sub}`);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
}
|
package/lib/commands/docs.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export async function docsCommand(ctx, args) {
|
|
|
13
13
|
ship docs feedback --title "..." --summary "..." [--recommendation "line"]... [--source-context "..."]
|
|
14
14
|
|
|
15
15
|
Vector search: ship search <query>
|
|
16
|
-
Catalog bodies: ship pattern|tool|
|
|
16
|
+
Catalog bodies: ship pattern|tool|collection fetch <id>
|
|
17
17
|
|
|
18
18
|
Global flags: --base-url URL --json`);
|
|
19
19
|
return;
|