mesa-ai-cto 0.1.0 → 0.2.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/dist/cli.js +5 -1
- package/dist/commands/init.js +113 -6
- package/dist/lib/exec.js +10 -2
- package/dist/lib/plan.js +123 -0
- package/dist/lib/progress.js +70 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { parseArgs } from "./lib/args.js";
|
|
|
7
7
|
* Returns an exit code rather than calling `process.exit`, so every path through the CLI
|
|
8
8
|
* is reachable from a test without tearing down the test runner.
|
|
9
9
|
*/
|
|
10
|
-
export const VERSION = "0.
|
|
10
|
+
export const VERSION = "0.2.0";
|
|
11
11
|
const HELP = `
|
|
12
12
|
mesa-ai-cto — scaffold a project from your AI CTO plan
|
|
13
13
|
|
|
@@ -19,6 +19,8 @@ const HELP = `
|
|
|
19
19
|
--stack=<id> pick the kit when a tier has more than one
|
|
20
20
|
--force scaffold into a folder that already has files
|
|
21
21
|
--no-install skip npm install
|
|
22
|
+
--token=<t> fetch your plan into plan.md
|
|
23
|
+
--api=<url> API base (the dashboard fills this in)
|
|
22
24
|
--session=<id> record which plan this came from
|
|
23
25
|
|
|
24
26
|
Coming next
|
|
@@ -71,6 +73,8 @@ export async function runCli(argv) {
|
|
|
71
73
|
*/
|
|
72
74
|
...(typeof flags.stack === "string" ? { stack: flags.stack } : {}),
|
|
73
75
|
...(typeof flags.session === "string" ? { session: flags.session } : {}),
|
|
76
|
+
...(typeof flags.token === "string" ? { token: flags.token } : {}),
|
|
77
|
+
...(typeof flags.api === "string" ? { api: flags.api } : {}),
|
|
74
78
|
});
|
|
75
79
|
process.stderr.write(output + "\n");
|
|
76
80
|
return code;
|
package/dist/commands/init.js
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { runDoctor } from "./doctor.js";
|
|
3
4
|
import { createExec } from "../lib/exec.js";
|
|
5
|
+
import { fetchPlan, renderPlanMarkdown } from "../lib/plan.js";
|
|
6
|
+
import { reportProgress, writeLocalState } from "../lib/progress.js";
|
|
7
|
+
import { VERSION } from "../cli.js";
|
|
4
8
|
import { defaultStack, kitId, templatesRoot } from "../lib/templates.js";
|
|
9
|
+
/**
|
|
10
|
+
* Things that may be present without the folder counting as occupied.
|
|
11
|
+
*
|
|
12
|
+
* None of them is the founder's work:
|
|
13
|
+
*
|
|
14
|
+
* - `.git` because initialising a repo and then scaffolding into it is a normal order of
|
|
15
|
+
* operations.
|
|
16
|
+
* - `node_modules` and the lockfiles because they are build output. The case that forced this
|
|
17
|
+
* was a founder who deleted a project to start over: Windows left an empty `node_modules`
|
|
18
|
+
* behind (it routinely fails to remove one, being deep and often locked) along with the
|
|
19
|
+
* lockfile, and the CLI then refused to scaffold into what was, to any human reading it, an
|
|
20
|
+
* empty folder. Being told to pass --force to overwrite nothing is a bad answer.
|
|
21
|
+
*/
|
|
22
|
+
const IGNORABLE = new Set([
|
|
23
|
+
".git",
|
|
24
|
+
"node_modules",
|
|
25
|
+
"package-lock.json",
|
|
26
|
+
"pnpm-lock.yaml",
|
|
27
|
+
"yarn.lock",
|
|
28
|
+
".DS_Store",
|
|
29
|
+
]);
|
|
5
30
|
/** Anything already in the target that means "this is not an empty folder". */
|
|
6
31
|
function isOccupied(dir) {
|
|
7
32
|
if (!existsSync(dir))
|
|
8
33
|
return false;
|
|
9
|
-
|
|
10
|
-
// operations, and refusing it would send people to --force for no reason.
|
|
11
|
-
return readdirSync(dir).some((entry) => entry !== ".git");
|
|
34
|
+
return readdirSync(dir).some((entry) => !IGNORABLE.has(entry));
|
|
12
35
|
}
|
|
13
36
|
/*
|
|
14
37
|
* The template's package.json is named for the boilerplate it came from. Left alone, every
|
|
@@ -121,6 +144,38 @@ export async function runInit(options) {
|
|
|
121
144
|
cli: "mesa-ai-cto",
|
|
122
145
|
}, null, 2)}\n`, "utf8");
|
|
123
146
|
lines.push(" ✓ mesa.json written");
|
|
147
|
+
/*
|
|
148
|
+
* The plan, if we were given a token to read it with.
|
|
149
|
+
*
|
|
150
|
+
* Non-fatal by design. The tree on disk is already correct and useful; an expired token or a
|
|
151
|
+
* laptop on a bad connection is worth a line of explanation, not a failed scaffold. What it
|
|
152
|
+
* must never do is write a plan.md it could not actually fetch: an agent treats that file as
|
|
153
|
+
* authoritative, so a wrong one is worse than none at all.
|
|
154
|
+
*/
|
|
155
|
+
let local = null;
|
|
156
|
+
if (options.token !== undefined) {
|
|
157
|
+
const fetched = await fetchPlan(options.token, {
|
|
158
|
+
name: options.name,
|
|
159
|
+
kit,
|
|
160
|
+
tier: options.tier,
|
|
161
|
+
stack,
|
|
162
|
+
cliVersion: VERSION,
|
|
163
|
+
}, options.api !== undefined ? { api: options.api } : {});
|
|
164
|
+
if (fetched.kind === "ok") {
|
|
165
|
+
writeFileSync(join(target, "plan.md"), renderPlanMarkdown(fetched.plan), "utf8");
|
|
166
|
+
lines.push(" ✓ plan.md written from your session");
|
|
167
|
+
// The report key arrives with the plan and is written straight to disk, gitignored.
|
|
168
|
+
local = { projectId: fetched.projectId, reportKey: fetched.reportKey };
|
|
169
|
+
writeLocalState(target, local);
|
|
170
|
+
lines.push(" ✓ progress linked to your dashboard");
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
lines.push(` ! plan.md not written: ${fetched.reason}`);
|
|
174
|
+
lines.push(" Copy the prompt from your dashboard instead.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** null = never attempted, so the step is PENDING rather than a claim either way. */
|
|
178
|
+
let depsInstalled = null;
|
|
124
179
|
if (options.install === false) {
|
|
125
180
|
lines.push(" · dependencies not installed (--no-install)");
|
|
126
181
|
}
|
|
@@ -131,7 +186,22 @@ export async function runInit(options) {
|
|
|
131
186
|
* fresh project routinely runs past a minute, and killing it at five seconds would report a
|
|
132
187
|
* timeout for a command that was working perfectly.
|
|
133
188
|
*/
|
|
134
|
-
|
|
189
|
+
/*
|
|
190
|
+
* Announced BEFORE it starts, and streamed while it runs.
|
|
191
|
+
*
|
|
192
|
+
* This step takes one to three minutes on a cold cache. Run silently it looks like the CLI
|
|
193
|
+
* has hung, and the natural response is to kill it — leaving a scaffolded project with no
|
|
194
|
+
* dependencies and no obvious way to tell that is what happened.
|
|
195
|
+
*/
|
|
196
|
+
process.stderr.write("\n Installing dependencies. This takes a minute or two.\n\n");
|
|
197
|
+
const outcome = await exec("npm", ["install"], {
|
|
198
|
+
cwd: target,
|
|
199
|
+
// The default is tuned for `--version` probes. A cold install routinely runs past a
|
|
200
|
+
// minute, and killing it at five seconds would report a timeout for a working command.
|
|
201
|
+
timeoutMs: 10 * 60_000,
|
|
202
|
+
stream: true,
|
|
203
|
+
});
|
|
204
|
+
depsInstalled = outcome.kind === "ok";
|
|
135
205
|
if (outcome.kind === "ok") {
|
|
136
206
|
lines.push(" ✓ dependencies installed");
|
|
137
207
|
}
|
|
@@ -147,14 +217,51 @@ export async function runInit(options) {
|
|
|
147
217
|
lines.push(` cd ${basename(target)} && npm install`);
|
|
148
218
|
}
|
|
149
219
|
}
|
|
220
|
+
/*
|
|
221
|
+
* Report what this run actually established.
|
|
222
|
+
*
|
|
223
|
+
* VERIFIED throughout, because every one of these is something the CLI did and observed
|
|
224
|
+
* rather than something a person claimed. `journey.installed` is a fact about the files on
|
|
225
|
+
* disk; `journey.dependencies` reflects the npm exit code we just read.
|
|
226
|
+
*
|
|
227
|
+
* Fire and forget: a failed report must never fail a good scaffold, so the outcome is a
|
|
228
|
+
* single line of output and nothing more.
|
|
229
|
+
*/
|
|
230
|
+
if (local !== null) {
|
|
231
|
+
const steps = [
|
|
232
|
+
{ stepId: "journey.installed", state: "DONE", source: "VERIFIED", detail: `${kit} scaffolded` },
|
|
233
|
+
{
|
|
234
|
+
stepId: "journey.dependencies",
|
|
235
|
+
state: depsInstalled === null ? "PENDING" : depsInstalled ? "DONE" : "FAILED",
|
|
236
|
+
source: "VERIFIED",
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
const sent = await reportProgress(local, steps, VERSION, options.api !== undefined ? { api: options.api } : {});
|
|
240
|
+
if (!sent)
|
|
241
|
+
lines.push(" · progress not reported (the dashboard will catch up next run)");
|
|
242
|
+
}
|
|
243
|
+
/*
|
|
244
|
+
* The machine report, after the scaffold rather than before it.
|
|
245
|
+
*
|
|
246
|
+
* A founder standing in a fresh project wants to know what still needs configuring - git
|
|
247
|
+
* identity, a GitHub login, a Vercel login - and finding that out by hitting the failure
|
|
248
|
+
* later is the expensive way. Running it here means the answer arrives with the project.
|
|
249
|
+
*
|
|
250
|
+
* Deliberately NOT a gate. Everything on disk is already correct, and refusing to scaffold
|
|
251
|
+
* because `gh` is not logged in would block work that does not need `gh` for hours yet.
|
|
252
|
+
* The exit code stays 0 and the report is information.
|
|
253
|
+
*/
|
|
254
|
+
const doctor = await runDoctor({ json: false });
|
|
255
|
+
lines.push("");
|
|
256
|
+
lines.push(doctor.output.trimEnd());
|
|
150
257
|
lines.push("");
|
|
151
258
|
lines.push(" Next:");
|
|
152
259
|
lines.push(` cd ${basename(target)}`);
|
|
153
260
|
lines.push(" Read INITIALIZE.md, then hand it to your coding agent.");
|
|
154
|
-
if (options.session !== undefined) {
|
|
261
|
+
if (options.token === undefined && options.session !== undefined) {
|
|
155
262
|
// Said plainly rather than silently omitted: the dashboard's command implies the plan
|
|
156
263
|
// travels with it, and today it does not.
|
|
157
|
-
lines.push("
|
|
264
|
+
lines.push(" Copy the prompt from your dashboard for the plan.");
|
|
158
265
|
}
|
|
159
266
|
lines.push("");
|
|
160
267
|
return { code: 0, output: ["", ...lines].join("\n") };
|
package/dist/lib/exec.js
CHANGED
|
@@ -69,8 +69,16 @@ export function createExec(deps = {}) {
|
|
|
69
69
|
}
|
|
70
70
|
let stdout = "";
|
|
71
71
|
let stderr = "";
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
if (opts.stream === true) {
|
|
73
|
+
// Everything to stderr, including the child's stdout: this CLI keeps stdout clean for
|
|
74
|
+
// `--json` payloads, and npm's chatter is commentary rather than output.
|
|
75
|
+
child.stdout?.pipe(process.stderr);
|
|
76
|
+
child.stderr?.pipe(process.stderr);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
child.stdout?.on("data", (d) => (stdout += d.toString()));
|
|
80
|
+
child.stderr?.on("data", (d) => (stderr += d.toString()));
|
|
81
|
+
}
|
|
74
82
|
timer = setTimeout(() => {
|
|
75
83
|
child.kill();
|
|
76
84
|
finish({ kind: "timeout" });
|
package/dist/lib/plan.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching the plan, and writing it as `plan.md`.
|
|
3
|
+
*
|
|
4
|
+
* The scaffold gives an agent the stack. This gives it the judgement: what to build, and — far
|
|
5
|
+
* more importantly — what the founder was talked out of. Without it an agent works from the
|
|
6
|
+
* project name and rebuilds the whole original wishlist.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Where the API lives.
|
|
10
|
+
*
|
|
11
|
+
* `MESA_API_URL` overrides it, which is what makes this testable against a local backend and
|
|
12
|
+
* survivable if the host ever moves without a CLI release. The default is the production API;
|
|
13
|
+
* a founder should never have to set an environment variable to run the command their dashboard
|
|
14
|
+
* printed.
|
|
15
|
+
*/
|
|
16
|
+
export function apiBase(env = process.env, override) {
|
|
17
|
+
return (override ?? env.MESA_API_URL ?? DEFAULT_API).replace(/\/+$/, "");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Only used when nothing else says otherwise.
|
|
21
|
+
*
|
|
22
|
+
* A hardcoded host in a published CLI is a hostage to fortune: it is wrong the moment the API
|
|
23
|
+
* moves, and every founder running an old version keeps hitting the old address. `--api` exists
|
|
24
|
+
* so the dashboard can state the answer in the command it renders, which means the CLI is
|
|
25
|
+
* correct wherever the backend happens to live, including a laptop during development.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_API = "https://api.mesa-ai-cto.com/api/v1";
|
|
28
|
+
/**
|
|
29
|
+
* Read the plan behind an install token.
|
|
30
|
+
*
|
|
31
|
+
* Never throws. A plan that cannot be fetched must not fail the scaffold: the project on disk
|
|
32
|
+
* is already correct, and an expired token or an offline laptop is a reason to print a line,
|
|
33
|
+
* not to delete a working tree.
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchPlan(token, facts, deps = {}) {
|
|
36
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
37
|
+
const query = new URLSearchParams({
|
|
38
|
+
token,
|
|
39
|
+
name: facts.name,
|
|
40
|
+
kit: facts.kit,
|
|
41
|
+
tier: String(facts.tier),
|
|
42
|
+
stack: facts.stack,
|
|
43
|
+
cliVersion: facts.cliVersion,
|
|
44
|
+
});
|
|
45
|
+
const url = `${apiBase(deps.env, deps.api)}/chat/install/plan?${query.toString()}`;
|
|
46
|
+
try {
|
|
47
|
+
const response = await doFetch(url, { headers: { accept: "application/json" } });
|
|
48
|
+
const body = (await response.json().catch(() => null));
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
return { kind: "failed", reason: body?.error?.message ?? `the server said ${response.status}` };
|
|
51
|
+
}
|
|
52
|
+
const { plan, projectId, reportKey } = body?.data ?? {};
|
|
53
|
+
if (!plan || !projectId || !reportKey) {
|
|
54
|
+
return { kind: "failed", reason: "the response was incomplete" };
|
|
55
|
+
}
|
|
56
|
+
return { kind: "ok", plan, projectId, reportKey };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { kind: "failed", reason: "the API could not be reached" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Defensive throughout: the plan's shape lives in a model prompt, so any field may be absent. */
|
|
63
|
+
function list(items) {
|
|
64
|
+
if (!Array.isArray(items))
|
|
65
|
+
return [];
|
|
66
|
+
return items
|
|
67
|
+
.map((item) => {
|
|
68
|
+
const f = item;
|
|
69
|
+
if (typeof f?.feature !== "string")
|
|
70
|
+
return null;
|
|
71
|
+
return typeof f.why === "string" ? `- **${f.feature}** — ${f.why}` : `- **${f.feature}**`;
|
|
72
|
+
})
|
|
73
|
+
.filter((line) => line !== null);
|
|
74
|
+
}
|
|
75
|
+
function section(heading, lines) {
|
|
76
|
+
return lines.length > 0 ? [`## ${heading}`, "", ...lines, ""] : [];
|
|
77
|
+
}
|
|
78
|
+
function str(value) {
|
|
79
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The plan as markdown, for an agent to read and a founder to check.
|
|
83
|
+
*
|
|
84
|
+
* Markdown rather than the raw JSON: this file's audience is a coding agent reading a repo, and
|
|
85
|
+
* every one of them handles markdown natively. Dropping a JSON blob in the project root would
|
|
86
|
+
* be a file the founder cannot read and the agent has to parse.
|
|
87
|
+
*/
|
|
88
|
+
export function renderPlanMarkdown(plan) {
|
|
89
|
+
const out = [];
|
|
90
|
+
out.push(`# ${str(plan.title) ?? "Build plan"}`, "");
|
|
91
|
+
const summary = [
|
|
92
|
+
str(plan.businessSummary),
|
|
93
|
+
str(plan.coreOutcome) ? `**The one thing it must do:** ${str(plan.coreOutcome)}` : null,
|
|
94
|
+
].filter((l) => l !== null);
|
|
95
|
+
if (summary.length > 0)
|
|
96
|
+
out.push(...summary, "");
|
|
97
|
+
const facts = [
|
|
98
|
+
str(plan.stack) ? `- **Stack:** ${str(plan.stack)} (already scaffolded)` : null,
|
|
99
|
+
typeof plan.tier === "number" ? `- **Tier:** ${plan.tier}` : null,
|
|
100
|
+
str(plan.formFactor) ? `- **Form factor:** ${str(plan.formFactor)}` : null,
|
|
101
|
+
typeof plan.buildEstimateDays === "number"
|
|
102
|
+
? `- **Budget:** about ${plan.buildEstimateDays} day(s) of work`
|
|
103
|
+
: null,
|
|
104
|
+
str(plan.stackReason) ? `- **Why this stack:** ${str(plan.stackReason)}` : null,
|
|
105
|
+
].filter((l) => l !== null);
|
|
106
|
+
out.push(...section("At a glance", facts));
|
|
107
|
+
out.push(...section("Build this — and only this", list(plan.p0)));
|
|
108
|
+
/*
|
|
109
|
+
* One "do not build" section, not a roadmap.
|
|
110
|
+
*
|
|
111
|
+
* Splitting these into "next" and "later" invites an agent to make a helpful start on the
|
|
112
|
+
* nearest one, which is the exact outcome the founder spent a conversation preventing.
|
|
113
|
+
*/
|
|
114
|
+
const notNow = [...list(plan.p1), ...list(plan.p2)];
|
|
115
|
+
out.push(...section("Do not build these yet", notNow.length > 0
|
|
116
|
+
? ["These were deliberately cut. Adding them back is a mistake, not a bonus.", "", ...notNow]
|
|
117
|
+
: []));
|
|
118
|
+
const milestone = str(plan.firstMilestone);
|
|
119
|
+
if (milestone !== null)
|
|
120
|
+
out.push(...section("First milestone", [milestone]));
|
|
121
|
+
out.push("---", "", "_Written by mesa-ai-cto from your AI CTO session. Re-run the command to refresh it._", "");
|
|
122
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
123
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { apiBase } from "./plan.js";
|
|
4
|
+
const DIR = ".mesa";
|
|
5
|
+
const FILE = "local.json";
|
|
6
|
+
/**
|
|
7
|
+
* Write the machine state, and keep it out of git.
|
|
8
|
+
*
|
|
9
|
+
* The report key is a long-lived bearer credential. Committed, it is one `git push` from being
|
|
10
|
+
* public — so `.mesa/` goes into `.gitignore` in the same breath that creates it, not as a
|
|
11
|
+
* separate step someone can forget.
|
|
12
|
+
*
|
|
13
|
+
* `.mesa/` is the one hidden directory in an otherwise recognisable project, and that is
|
|
14
|
+
* deliberate: everything a founder should read is visible at the root, and this is the only
|
|
15
|
+
* thing that is purely machine state.
|
|
16
|
+
*/
|
|
17
|
+
export function writeLocalState(projectRoot, state) {
|
|
18
|
+
const dir = join(projectRoot, DIR);
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
writeFileSync(join(dir, FILE), `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
21
|
+
const gitignore = join(projectRoot, ".gitignore");
|
|
22
|
+
const existing = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
|
23
|
+
// Matched on a line boundary: a bare `includes(".mesa")` would be satisfied by an unrelated
|
|
24
|
+
// path that merely contains the string.
|
|
25
|
+
if (!/^\.mesa\/?\s*$/m.test(existing)) {
|
|
26
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
27
|
+
appendFileSync(gitignore, `${prefix}\n# Machine state for mesa-ai-cto (holds a report key)\n.mesa/\n`, "utf8");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function readLocalState(projectRoot) {
|
|
31
|
+
const file = join(projectRoot, DIR, FILE);
|
|
32
|
+
if (!existsSync(file))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
36
|
+
if (typeof parsed.projectId !== "string" || typeof parsed.reportKey !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
return { projectId: parsed.projectId, reportKey: parsed.reportKey };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// A corrupt state file is not worth failing over: the project still works, it just stops
|
|
42
|
+
// reporting until the founder reruns the command.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Post steps. Never throws.
|
|
48
|
+
*
|
|
49
|
+
* Progress reporting is telemetry about a build, not part of it. A founder on a plane must still
|
|
50
|
+
* get a working scaffold, so every failure here is swallowed and reported as a boolean the
|
|
51
|
+
* caller can mention in passing.
|
|
52
|
+
*/
|
|
53
|
+
export async function reportProgress(state, steps, cliVersion, deps = {}) {
|
|
54
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
55
|
+
try {
|
|
56
|
+
const response = await doFetch(`${apiBase(deps.env, deps.api)}/projects/progress`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
"content-type": "application/json",
|
|
60
|
+
// The report key is the credential. It names one project and can do nothing else.
|
|
61
|
+
authorization: `Bearer ${state.reportKey}`,
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({ cliVersion, steps }),
|
|
64
|
+
});
|
|
65
|
+
return response.ok;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|