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 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.1.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;
@@ -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
- // `.git` alone is fine: initialising a repo and then scaffolding into it is a normal order of
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
- const outcome = await exec("npm", ["install"], { cwd: target, timeoutMs: 10 * 60_000 });
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(" Paste your plan from the dashboard — it is not fetched yet.");
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
- child.stdout?.on("data", (d) => (stdout += d.toString()));
73
- child.stderr?.on("data", (d) => (stderr += d.toString()));
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" });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mesa-ai-cto",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Check your machine and scaffold a production-grade project from an AI CTO plan.",
5
5
  "keywords": [
6
6
  "scaffold",