svcloud 0.1.3 → 0.1.5
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 +59 -4
- package/package.json +1 -1
- package/src/commands/bundle.ts +224 -0
- package/src/commands/init.ts +21 -4
- package/src/commands/mcp-check.ts +36 -1
- package/src/commands/mcp-setup.ts +76 -12
- package/src/commands/storage.ts +108 -0
- package/src/index.ts +10 -0
- package/src/lib/api.ts +22 -2
- package/src/lib/config.ts +61 -6
- package/src/lib/credentials.ts +33 -17
- package/src/lib/harnesses.ts +65 -6
- package/src/lib/self.ts +88 -0
package/README.md
CHANGED
|
@@ -8,17 +8,33 @@ the way an AI coding agent reaches the platform.
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
Installing `svcloud` is a required step of setting up an SV Cloud project, not
|
|
12
|
+
an optional extra: it is how you sign in, and it is the only way your coding
|
|
13
|
+
agent reaches the platform's tools.
|
|
14
|
+
|
|
11
15
|
```bash
|
|
12
16
|
npm install -g svcloud
|
|
17
|
+
svcloud --version
|
|
13
18
|
svcloud login
|
|
14
19
|
```
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
The `--version` line is there to be run, not skipped. If it prints a version
|
|
22
|
+
you are done. If it prints `command not found`, the install worked but your
|
|
23
|
+
npm global bin directory is not on your shell's `PATH`:
|
|
17
24
|
|
|
18
25
|
```bash
|
|
19
|
-
|
|
26
|
+
echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
|
|
20
27
|
```
|
|
21
28
|
|
|
29
|
+
Use `~/.bashrc` instead of `~/.zshrc` on most Linux shells. Then re-run
|
|
30
|
+
`svcloud --version`. `svcloud mcp check` reports the same thing, along with
|
|
31
|
+
everything else about your setup.
|
|
32
|
+
|
|
33
|
+
`npx svcloud <command>` works too, and is fine for trying one command. It is
|
|
34
|
+
the wrong thing to leave in place: every invocation re-resolves the package
|
|
35
|
+
from the npm registry, which makes the registry a startup dependency of your
|
|
36
|
+
coding agent's tools.
|
|
37
|
+
|
|
22
38
|
An installed `svcloud` is what every example below uses, and is what a
|
|
23
39
|
coding agent's harness config should point at too — see [Connect your coding
|
|
24
40
|
agent](#connect-your-coding-agent).
|
|
@@ -41,7 +57,9 @@ svcloud init Connect a repo (defaults to the one in your workin
|
|
|
41
57
|
svcloud runs <app> Show or watch an app's provisioning run
|
|
42
58
|
svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
|
|
43
59
|
svcloud db <cmd> Browse and edit an app's database (see `svcloud db` for subcommands)
|
|
60
|
+
svcloud storage presign Get a direct URL for one file in an app's file storage
|
|
44
61
|
svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
|
|
62
|
+
svcloud bundle <cmd> Keep an app's starter current (status/update/done)
|
|
45
63
|
svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
|
|
46
64
|
svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
|
|
47
65
|
svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
|
|
@@ -59,6 +77,33 @@ to skip what it would otherwise infer or ask about; `runs` reads `--id
|
|
|
59
77
|
|
|
60
78
|
`logs` and `dev` are still on the way.
|
|
61
79
|
|
|
80
|
+
### Keeping an app's starter current
|
|
81
|
+
|
|
82
|
+
SV Cloud's starter gets better over time, and an app that never catches up
|
|
83
|
+
keeps working but stops being able to do things the platform can now do. It can
|
|
84
|
+
also keep a deploy step that fails for reasons nothing in the repo explains.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
svcloud bundle status <app> # is this app behind?
|
|
88
|
+
svcloud bundle update <app> # stage the update for review
|
|
89
|
+
svcloud bundle done <app> # record it, after you commit
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`update` writes the new versions into `sv-cloud-bundle/` along with an
|
|
93
|
+
`UPDATE.md` explaining what to do with each file. It changes nothing in your
|
|
94
|
+
app's own source, and it does not commit or push — you read the diff, and
|
|
95
|
+
`git` stays the undo. Most people will hand this to their coding agent
|
|
96
|
+
instead: it can read the same plan through SV Cloud's tools, and it knows
|
|
97
|
+
which files it may replace outright and which ones it has to merge into work
|
|
98
|
+
you did.
|
|
99
|
+
|
|
100
|
+
`update` refuses to run with uncommitted changes, so that the diff it produces
|
|
101
|
+
means exactly one thing.
|
|
102
|
+
|
|
103
|
+
Run `svcloud bundle done` once the changes are committed. Skipping it does no
|
|
104
|
+
damage, but the app keeps reporting itself out of date and the next update
|
|
105
|
+
starts from the wrong place.
|
|
106
|
+
|
|
62
107
|
## Connect your coding agent
|
|
63
108
|
|
|
64
109
|
`svcloud mcp` runs a local MCP server that your coding agent — Antigravity,
|
|
@@ -85,8 +130,18 @@ actually expects — the config blocks below are the same data, by hand, for a
|
|
|
85
130
|
harness that isn't in the list yet, or if you'd rather see exactly what gets
|
|
86
131
|
written first. If something still doesn't work after setup, run
|
|
87
132
|
`svcloud mcp check` — it reports sign-in state, a live count of tools your
|
|
88
|
-
token can see,
|
|
89
|
-
the right shape.
|
|
133
|
+
token can see, whether `svcloud` is on your PATH, and whether each known
|
|
134
|
+
harness's config file exists and has the right shape.
|
|
135
|
+
|
|
136
|
+
**A note on `"command": "svcloud"` in the blocks below.** Your harness spawns
|
|
137
|
+
that command itself, with its own environment, so the bare name only works if
|
|
138
|
+
`svcloud` is on the PATH that harness inherits — and a harness that cannot
|
|
139
|
+
spawn it shows you no error at all, just an MCP server with no tools.
|
|
140
|
+
`svcloud mcp setup` checks first and writes an absolute path instead when the
|
|
141
|
+
bare name would not resolve, which is one more reason to let it write the
|
|
142
|
+
file rather than copying a block by hand. If you are writing one by hand and
|
|
143
|
+
`svcloud --version` does not work in a fresh terminal, fix your PATH first
|
|
144
|
+
(see [Install](#install)).
|
|
90
145
|
|
|
91
146
|
### Antigravity
|
|
92
147
|
Add to `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json` (workspace):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svcloud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "The SV Cloud CLI. Alpha: login, logout, status, open, projects list, mcp, init, and runs are built; see PLANNING.md for what's still missing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `svcloud bundle` — PLANNING.md §4 Step 8, apps/cloud F27. Brings a connected
|
|
3
|
+
* repo up to a newer starter template.
|
|
4
|
+
*
|
|
5
|
+
* The merge rules are the platform's, not the CLI's: `GET /bundle/plan`
|
|
6
|
+
* returns the base version's bytes, the new version's bytes, and a per-file
|
|
7
|
+
* action, and everything here is staging and presentation. That is deliberate,
|
|
8
|
+
* for the same reason the MCP bridge holds no tool registry — a rule that
|
|
9
|
+
* lives on the server cannot drift against the version of the CLI somebody
|
|
10
|
+
* happens to have installed.
|
|
11
|
+
*
|
|
12
|
+
* `update` writes into `sv-cloud-bundle/` and stops. It does not `git add`,
|
|
13
|
+
* commit, or push, and it never touches `src/`: the owner reads a diff, and
|
|
14
|
+
* git stays the undo. `done` is a separate command precisely because it has to
|
|
15
|
+
* run AFTER a human commits — folding it into `update` would mean recording
|
|
16
|
+
* that the update landed before it had.
|
|
17
|
+
*/
|
|
18
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { apiFetch } from "../lib/api";
|
|
21
|
+
import { findProjectBySlug } from "../lib/find-project";
|
|
22
|
+
import { gitWorkingState } from "../lib/git";
|
|
23
|
+
import { die, printJson, printTable } from "../lib/output";
|
|
24
|
+
|
|
25
|
+
const STAGE_DIR = "sv-cloud-bundle";
|
|
26
|
+
|
|
27
|
+
interface BundleStatus {
|
|
28
|
+
project_id: string;
|
|
29
|
+
current_version: string | null;
|
|
30
|
+
latest_version: string;
|
|
31
|
+
update_available: boolean;
|
|
32
|
+
legacy: boolean;
|
|
33
|
+
base_unavailable_reason: string | null;
|
|
34
|
+
workflow_stale: boolean;
|
|
35
|
+
workflow_warning: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PlannedFile {
|
|
39
|
+
src: string;
|
|
40
|
+
path: string;
|
|
41
|
+
class: string;
|
|
42
|
+
action: string;
|
|
43
|
+
target?: string;
|
|
44
|
+
base?: string;
|
|
45
|
+
guidance: string;
|
|
46
|
+
retirement?: { src: string; replaced_by?: string | null; note: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BundlePlan extends BundleStatus {
|
|
50
|
+
framework: string;
|
|
51
|
+
migrate_once: boolean;
|
|
52
|
+
files: PlannedFile[];
|
|
53
|
+
migration_notes: string | null;
|
|
54
|
+
manifest_merge: unknown;
|
|
55
|
+
instructions: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Human-facing one-liners. The wire's `action` stays machine-readable. */
|
|
59
|
+
const ACTION_LABEL: Record<string, string> = {
|
|
60
|
+
skip: "unchanged",
|
|
61
|
+
"fast-forward": "replace",
|
|
62
|
+
conflict: "merge (you edited it)",
|
|
63
|
+
create: "new file",
|
|
64
|
+
retire: "delete",
|
|
65
|
+
"retire-conflict": "retired, you use it",
|
|
66
|
+
locate: "find it first",
|
|
67
|
+
"merge-manifest": "merge fields",
|
|
68
|
+
"report-only": "yours, unchanged",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
async function resolve(slug: string | undefined): Promise<{ id: string; name: string }> {
|
|
72
|
+
if (!slug) die("Usage: svcloud bundle <status|update|done> <app>");
|
|
73
|
+
const project = await findProjectBySlug(slug);
|
|
74
|
+
if (!project) die(`No app named "${slug}".`);
|
|
75
|
+
return { id: project.id, name: project.name };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function statusCmd(slug: string | undefined, json: boolean): Promise<void> {
|
|
79
|
+
const project = await resolve(slug);
|
|
80
|
+
const status = await apiFetch<BundleStatus>(`/api/v1/projects/${project.id}/bundle`);
|
|
81
|
+
if (json) {
|
|
82
|
+
printJson(status);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log(`App: ${project.name}`);
|
|
86
|
+
console.log(`Starter: ${status.current_version ?? "not recorded"}`);
|
|
87
|
+
console.log(`Newest: ${status.latest_version}`);
|
|
88
|
+
if (status.workflow_warning) console.log(`\n! ${status.workflow_warning}`);
|
|
89
|
+
if (!status.update_available) {
|
|
90
|
+
console.log("\nUp to date.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (status.legacy) {
|
|
94
|
+
console.log(
|
|
95
|
+
"\nThis app was set up before starter versions were recorded, so the first update " +
|
|
96
|
+
"has no starting point to compare against. Review every change it proposes.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
console.log("\nAn update is available. Run `svcloud bundle update " + (slug ?? "") + "`.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function updateCmd(slug: string | undefined, json: boolean): Promise<void> {
|
|
103
|
+
const project = await resolve(slug);
|
|
104
|
+
|
|
105
|
+
// Same preconditions as `deploy`, for the same reason: this writes files
|
|
106
|
+
// into the working tree, and a dirty tree makes the resulting diff
|
|
107
|
+
// impossible for the owner to read as "what the update did".
|
|
108
|
+
const state = await gitWorkingState();
|
|
109
|
+
if (!state) {
|
|
110
|
+
die("Run this from the app's local git repository (no repo found, or `git` isn't on PATH).");
|
|
111
|
+
}
|
|
112
|
+
if (state.dirty) {
|
|
113
|
+
die(
|
|
114
|
+
"Working tree has uncommitted changes. Commit or stash them first, so the update's own " +
|
|
115
|
+
"changes are the only thing in the diff.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const plan = await apiFetch<BundlePlan>(`/api/v1/projects/${project.id}/bundle/plan`);
|
|
120
|
+
if (json) {
|
|
121
|
+
printJson(plan);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!plan.update_available) {
|
|
126
|
+
console.log(`${project.name} is already on the newest starter (${plan.latest_version}).`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const actionable = plan.files.filter((f) => f.action !== "skip");
|
|
131
|
+
const cwd = process.cwd();
|
|
132
|
+
const written: string[] = [];
|
|
133
|
+
|
|
134
|
+
for (const f of actionable) {
|
|
135
|
+
if (f.target === undefined) continue;
|
|
136
|
+
const staged = join(cwd, STAGE_DIR, "files", f.src);
|
|
137
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
138
|
+
await writeFile(staged, f.target, "utf8");
|
|
139
|
+
written.push(f.src);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const notes = [
|
|
143
|
+
`# Starter update: ${plan.current_version ?? "unrecorded"} to ${plan.latest_version}`,
|
|
144
|
+
"",
|
|
145
|
+
plan.instructions,
|
|
146
|
+
"",
|
|
147
|
+
plan.base_unavailable_reason ? `**Note.** ${plan.base_unavailable_reason}\n` : "",
|
|
148
|
+
"## Files",
|
|
149
|
+
"",
|
|
150
|
+
...actionable.map((f) => {
|
|
151
|
+
const head = `### ${f.path} — ${ACTION_LABEL[f.action] ?? f.action}`;
|
|
152
|
+
const staged =
|
|
153
|
+
f.target === undefined ? "" : `\nNew version staged at \`${STAGE_DIR}/files/${f.src}\`.`;
|
|
154
|
+
const retired = f.retirement
|
|
155
|
+
? `\nRetired${f.retirement.replaced_by ? `, superseded by ${f.retirement.replaced_by}` : ""}. ${f.retirement.note}`
|
|
156
|
+
: "";
|
|
157
|
+
return `${head}\n\n${f.guidance}${staged}${retired}\n`;
|
|
158
|
+
}),
|
|
159
|
+
plan.migration_notes ? `## Migration notes\n\n${plan.migration_notes}\n` : "",
|
|
160
|
+
].join("\n");
|
|
161
|
+
|
|
162
|
+
await writeFile(join(cwd, STAGE_DIR, "UPDATE.md"), notes, "utf8");
|
|
163
|
+
|
|
164
|
+
printTable(
|
|
165
|
+
actionable.map((f) => ({
|
|
166
|
+
file: f.path,
|
|
167
|
+
what: ACTION_LABEL[f.action] ?? f.action,
|
|
168
|
+
})),
|
|
169
|
+
["file", "what"],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
console.log(`\nStaged ${written.length} file(s) under ${STAGE_DIR}/ — nothing in src/ changed.`);
|
|
173
|
+
console.log(`Read ${STAGE_DIR}/UPDATE.md, or open this repo with your coding agent and ask it`);
|
|
174
|
+
console.log("to apply the starter update; it can read the same plan through SV Cloud's tools.");
|
|
175
|
+
console.log(`\nWhen the changes are committed, run \`svcloud bundle done ${slug ?? ""}\`.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function doneCmd(slug: string | undefined, json: boolean): Promise<void> {
|
|
179
|
+
const project = await resolve(slug);
|
|
180
|
+
const result = await apiFetch<{
|
|
181
|
+
version: string;
|
|
182
|
+
marker_path: string;
|
|
183
|
+
marker_contents: string;
|
|
184
|
+
changed: boolean;
|
|
185
|
+
}>(`/api/v1/projects/${project.id}/bundle/applied`, { method: "POST", body: {} });
|
|
186
|
+
|
|
187
|
+
// The marker is written HERE rather than by the server, because the server
|
|
188
|
+
// does not touch repos — same rule the rest of this feature follows.
|
|
189
|
+
const target = join(process.cwd(), result.marker_path);
|
|
190
|
+
await mkdir(dirname(target), { recursive: true });
|
|
191
|
+
await writeFile(target, result.marker_contents, "utf8");
|
|
192
|
+
|
|
193
|
+
if (json) {
|
|
194
|
+
printJson(result);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
console.log(`Recorded ${project.name} as running starter ${result.version}.`);
|
|
198
|
+
console.log(`Wrote ${result.marker_path}. Commit it — it is how the next update knows where to`);
|
|
199
|
+
console.log("start, and an app whose marker disagrees with its files updates badly.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function bundleCommand(argv: string[], json: boolean): Promise<void> {
|
|
203
|
+
const [sub, ...rest] = argv;
|
|
204
|
+
switch (sub) {
|
|
205
|
+
case "status":
|
|
206
|
+
await statusCmd(rest[0], json);
|
|
207
|
+
return;
|
|
208
|
+
case "update":
|
|
209
|
+
await updateCmd(rest[0], json);
|
|
210
|
+
return;
|
|
211
|
+
case "done":
|
|
212
|
+
await doneCmd(rest[0], json);
|
|
213
|
+
return;
|
|
214
|
+
default:
|
|
215
|
+
die(
|
|
216
|
+
[
|
|
217
|
+
"Usage:",
|
|
218
|
+
" svcloud bundle status <app> Is this app running an out-of-date starter?",
|
|
219
|
+
" svcloud bundle update <app> Stage the update under sv-cloud-bundle/ for review",
|
|
220
|
+
" svcloud bundle done <app> Record the update after you have committed it",
|
|
221
|
+
].join("\n"),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -229,8 +229,25 @@ export async function initCommand(argv: string[], json: boolean): Promise<void>
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
console.log("");
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
// `needs_initialization` is the honest discriminator and the reason this
|
|
233
|
+
// prints two different sentences. An EMPTY target repo got the starter at
|
|
234
|
+
// its real paths, so GitHub Actions is already building it and the app
|
|
235
|
+
// comes up on its own; a repo that already had code got the starter nested
|
|
236
|
+
// inert under `sv-cloud-bundle/` and needs an agent to unpack it first.
|
|
237
|
+
// Claiming either one for both was the old message's problem.
|
|
238
|
+
if (project.needs_initialization) {
|
|
239
|
+
console.log(`Your app is at ${project.web_address ?? "(no web address yet)"} — not live yet.`);
|
|
240
|
+
console.log(
|
|
241
|
+
"Your repository already had files in it, so the starter is sitting in sv-cloud-bundle/.",
|
|
242
|
+
);
|
|
243
|
+
console.log(
|
|
244
|
+
"Open the repo with a coding agent (it'll read SVAGENTS.md and unpack it), then push.",
|
|
245
|
+
);
|
|
246
|
+
} else {
|
|
247
|
+
console.log(`Your app is at ${project.web_address ?? "(no web address yet)"}.`);
|
|
248
|
+
console.log(
|
|
249
|
+
"The starter code is committed and GitHub Actions is building it now — give it a couple of minutes.",
|
|
250
|
+
);
|
|
251
|
+
console.log(`Watch it with: svcloud status ${project.slug}`);
|
|
252
|
+
}
|
|
236
253
|
}
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* found, right shape or wrong.
|
|
10
10
|
*/
|
|
11
11
|
import { callRemote } from "./mcp";
|
|
12
|
-
import { CLI_VERSION, apiBaseUrl } from "../lib/config";
|
|
12
|
+
import { CLI_VERSION, PRODUCTION_API_URL, apiBaseUrl } from "../lib/config";
|
|
13
13
|
import { loadTokens } from "../lib/credentials";
|
|
14
14
|
import { HARNESSES, inspectHarness, type HarnessStatus } from "../lib/harnesses";
|
|
15
15
|
import { printJson } from "../lib/output";
|
|
16
|
+
import { bridgeCommand, isOnPath, pathHint } from "../lib/self";
|
|
16
17
|
|
|
17
18
|
// The scope vocabulary svcloud-cli/svcloud-mcp currently grant by default
|
|
18
19
|
// (apps/cloud/src/services/oauth.ts's OAUTH_CLIENTS, widened 2026-08-25 to
|
|
@@ -21,6 +22,20 @@ import { printJson } from "../lib/output";
|
|
|
21
22
|
// and only refreshed by signing in again, never silently upgraded.
|
|
22
23
|
const EXPECTED_SCOPE_COUNT = 6;
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A configured entry can still be pointed at a different deployment than the
|
|
27
|
+
* shell running this check — shape is right, target is wrong. That mismatch
|
|
28
|
+
* is invisible in the config file and produces an agent that silently reads
|
|
29
|
+
* and writes the wrong environment, so it is called out by name rather than
|
|
30
|
+
* folded into "configured correctly".
|
|
31
|
+
*/
|
|
32
|
+
function describeTarget(status: HarnessStatus): string | undefined {
|
|
33
|
+
if (status.state !== "ok") return undefined;
|
|
34
|
+
const entryUrl = status.apiUrl ?? PRODUCTION_API_URL;
|
|
35
|
+
if (entryUrl === apiBaseUrl()) return undefined;
|
|
36
|
+
return `points the bridge at ${entryUrl}, not ${apiBaseUrl()} — rerun \`svcloud mcp setup\` from this shell to repoint it`;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
function describeStatus(status: HarnessStatus): string {
|
|
25
40
|
switch (status.state) {
|
|
26
41
|
case "not_found":
|
|
@@ -39,6 +54,12 @@ function describeStatus(status: HarnessStatus): string {
|
|
|
39
54
|
export async function mcpCheckCommand(json: boolean): Promise<void> {
|
|
40
55
|
const tokens = await loadTokens();
|
|
41
56
|
const cwd = process.cwd();
|
|
57
|
+
// A global install whose bin directory is not on PATH is invisible until
|
|
58
|
+
// something tries to spawn `svcloud` and gets ENOENT — which, for a
|
|
59
|
+
// harness, looks exactly like "SV Cloud has no tools". Reported here
|
|
60
|
+
// rather than left for the owner to work out.
|
|
61
|
+
const onPath = isOnPath();
|
|
62
|
+
const bridge = bridgeCommand();
|
|
42
63
|
|
|
43
64
|
const harnessResults = await Promise.all(
|
|
44
65
|
HARNESSES.map(async (h) => ({ harness: h, status: await inspectHarness(h, cwd) })),
|
|
@@ -67,6 +88,8 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
|
|
|
67
88
|
printJson({
|
|
68
89
|
version: CLI_VERSION,
|
|
69
90
|
api_base_url: apiBaseUrl(),
|
|
91
|
+
svcloud_on_path: onPath,
|
|
92
|
+
bridge_command: [bridge.command, ...bridge.args],
|
|
70
93
|
signed_in: tokens !== undefined,
|
|
71
94
|
scopes: tokens?.scopes ?? [],
|
|
72
95
|
scopes_possibly_stale: scopeStale,
|
|
@@ -76,6 +99,7 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
|
|
|
76
99
|
id: harness.id,
|
|
77
100
|
path: status.path,
|
|
78
101
|
state: status.state,
|
|
102
|
+
api_url: status.state === "ok" ? (status.apiUrl ?? PRODUCTION_API_URL) : undefined,
|
|
79
103
|
})),
|
|
80
104
|
});
|
|
81
105
|
return;
|
|
@@ -83,6 +107,15 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
|
|
|
83
107
|
|
|
84
108
|
console.log(`svcloud ${CLI_VERSION} (API: ${apiBaseUrl()})`);
|
|
85
109
|
console.log("");
|
|
110
|
+
if (onPath) {
|
|
111
|
+
console.log("PATH: `svcloud` resolves on your PATH.");
|
|
112
|
+
} else {
|
|
113
|
+
console.log(pathHint());
|
|
114
|
+
console.log(
|
|
115
|
+
"Harness configs written by `svcloud mcp setup` use this install's absolute path, so they still work.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
console.log("");
|
|
86
119
|
if (!tokens) {
|
|
87
120
|
console.log("Sign-in: not signed in. Run `svcloud login`.");
|
|
88
121
|
} else {
|
|
@@ -104,6 +137,8 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
|
|
|
104
137
|
for (const { harness, status } of harnessResults) {
|
|
105
138
|
console.log(` ${harness.label.padEnd(14)} ${status.path}`);
|
|
106
139
|
console.log(` ${"".padEnd(14)} ${describeStatus(status)}`);
|
|
140
|
+
const target = describeTarget(status);
|
|
141
|
+
if (target) console.log(` ${"".padEnd(14)} ${target}`);
|
|
107
142
|
}
|
|
108
143
|
console.log("");
|
|
109
144
|
console.log("Fix a missing or wrong-shaped one with: svcloud mcp setup <harness>");
|
|
@@ -9,8 +9,34 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
11
|
import { dirname } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { apiBaseUrl, PRODUCTION_API_URL } from "../lib/config";
|
|
13
|
+
import { findHarness, HARNESSES, type Harness, readEntryApiUrl } from "../lib/harnesses";
|
|
13
14
|
import { die, hasFlag } from "../lib/output";
|
|
15
|
+
import { bridgeCommand, pathHint } from "../lib/self";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The environment the written entry should spawn the bridge with — almost
|
|
19
|
+
* always none.
|
|
20
|
+
*
|
|
21
|
+
* A harness config records a command, and the harness spawns it later from
|
|
22
|
+
* its own environment, not from the shell that ran `mcp setup`. So a bridge
|
|
23
|
+
* set up in a staging shell would still have talked to PRODUCTION every time
|
|
24
|
+
* the agent called a tool: `svcloud mcp setup` could not point an agent at
|
|
25
|
+
* anything but production at all, which is the half of the staging problem
|
|
26
|
+
* that no amount of care at the terminal could work around.
|
|
27
|
+
*
|
|
28
|
+
* Inherited rather than given a flag, deliberately. Staging is ours, not the
|
|
29
|
+
* product's, so there is no `--env` for a user to find: this reads the
|
|
30
|
+
* ambient `SVCLOUD_API_URL` and writes nothing at all when it is unset or
|
|
31
|
+
* already production, which is every real user. Their config file is
|
|
32
|
+
* byte-identical to what this command wrote before passthrough existed.
|
|
33
|
+
*/
|
|
34
|
+
function inheritedEnv(): Record<string, string> | undefined {
|
|
35
|
+
if (!process.env.SVCLOUD_API_URL) return undefined;
|
|
36
|
+
const base = apiBaseUrl();
|
|
37
|
+
if (base === PRODUCTION_API_URL) return undefined;
|
|
38
|
+
return { SVCLOUD_API_URL: base };
|
|
39
|
+
}
|
|
14
40
|
|
|
15
41
|
function usage(): string {
|
|
16
42
|
const ids = HARNESSES.map((h) => h.id).join(" | ");
|
|
@@ -53,21 +79,36 @@ export async function mcpSetupCommand(argv: string[], json: boolean): Promise<vo
|
|
|
53
79
|
|
|
54
80
|
const servers = (config[harness.serversKey] ?? {}) as Record<string, unknown>;
|
|
55
81
|
const existing = servers.svcloud;
|
|
56
|
-
|
|
82
|
+
// Resolved, never assumed: a config that says `svcloud` on a machine
|
|
83
|
+
// where `svcloud` is not on PATH produces a harness that silently has no
|
|
84
|
+
// SV Cloud tools at all. See lib/self.ts.
|
|
85
|
+
const bridge = bridgeCommand();
|
|
86
|
+
const env = inheritedEnv();
|
|
87
|
+
const entry = harness.buildEntry(bridge.command, bridge.args, env);
|
|
57
88
|
|
|
58
89
|
if (existing !== undefined && !force) {
|
|
59
90
|
if (harness.isWellFormed(existing)) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
// A well-formed entry pointed at a DIFFERENT deployment than this
|
|
92
|
+
// shell is not "already configured" — reporting it as such is how an
|
|
93
|
+
// agent ends up silently talking to production from a staging setup,
|
|
94
|
+
// and the reverse. Shape matches, so this is a correction of one
|
|
95
|
+
// value rather than the overwrite `--force` guards, and it falls
|
|
96
|
+
// through to the write below.
|
|
97
|
+
const desired = env?.SVCLOUD_API_URL;
|
|
98
|
+
if (readEntryApiUrl(existing) === desired) {
|
|
99
|
+
if (json) {
|
|
100
|
+
console.log(JSON.stringify({ harness: harness.id, path, status: "already_configured" }));
|
|
101
|
+
} else {
|
|
102
|
+
console.log(`${harness.label} is already configured at ${path}.`);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
64
105
|
}
|
|
65
|
-
|
|
106
|
+
} else {
|
|
107
|
+
die(
|
|
108
|
+
`${path} already has a "svcloud" entry under "${harness.serversKey}", but it doesn't look right for ${harness.label}.\n` +
|
|
109
|
+
`Rerun with --force to overwrite it.`,
|
|
110
|
+
);
|
|
66
111
|
}
|
|
67
|
-
die(
|
|
68
|
-
`${path} already has a "svcloud" entry under "${harness.serversKey}", but it doesn't look right for ${harness.label}.\n` +
|
|
69
|
-
`Rerun with --force to overwrite it.`,
|
|
70
|
-
);
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
servers.svcloud = entry;
|
|
@@ -77,10 +118,33 @@ export async function mcpSetupCommand(argv: string[], json: boolean): Promise<vo
|
|
|
77
118
|
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
78
119
|
|
|
79
120
|
if (json) {
|
|
80
|
-
console.log(
|
|
121
|
+
console.log(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
harness: harness.id,
|
|
124
|
+
path,
|
|
125
|
+
status: "written",
|
|
126
|
+
command: bridge.command,
|
|
127
|
+
args: bridge.args,
|
|
128
|
+
svcloud_on_path: bridge.onPath,
|
|
129
|
+
// Only when there is something to say: production writes no
|
|
130
|
+
// environment block, and reporting a key for it would imply one.
|
|
131
|
+
...(env ? { api_url: env.SVCLOUD_API_URL } : {}),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
81
134
|
return;
|
|
82
135
|
}
|
|
83
136
|
console.log(`Wrote ${harness.label}'s MCP config to ${path}.`);
|
|
137
|
+
if (env) {
|
|
138
|
+
console.log(`This entry points the bridge at ${env.SVCLOUD_API_URL}.`);
|
|
139
|
+
}
|
|
140
|
+
if (!bridge.onPath) {
|
|
141
|
+
console.log("");
|
|
142
|
+
console.log(
|
|
143
|
+
"Note: the config uses this install's absolute path, so your harness will find it either way.",
|
|
144
|
+
);
|
|
145
|
+
console.log(pathHint());
|
|
146
|
+
console.log("");
|
|
147
|
+
}
|
|
84
148
|
console.log(`Sign in first if you haven't: svcloud login`);
|
|
85
149
|
console.log(`Then restart ${harness.label} (or start a new session) to pick it up.`);
|
|
86
150
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `svcloud storage` — direct URLs into an app's file storage.
|
|
3
|
+
*
|
|
4
|
+
* Only presigning, deliberately. `svcloud storage put` and `get` would put the
|
|
5
|
+
* CLI in the data path for a file that may be gigabytes, which is the exact
|
|
6
|
+
* cost presigned URLs exist to avoid; the URL this hands back works with curl,
|
|
7
|
+
* a browser, or anything else, and moves bytes straight between the caller and
|
|
8
|
+
* storage.
|
|
9
|
+
*
|
|
10
|
+
* A GET URL is a shareable download link for as long as it lives. The server
|
|
11
|
+
* caps that lifetime and reports what it actually granted, which may be less
|
|
12
|
+
* than was asked for — so `expires_in` in the output is the truth, not the
|
|
13
|
+
* `--expires-in` flag.
|
|
14
|
+
*/
|
|
15
|
+
import { statSync } from "node:fs";
|
|
16
|
+
import { apiFetch } from "../lib/api";
|
|
17
|
+
import { findProjectBySlug } from "../lib/find-project";
|
|
18
|
+
import { die, printJson } from "../lib/output";
|
|
19
|
+
|
|
20
|
+
const USAGE = `Usage:
|
|
21
|
+
svcloud storage presign <app> <key> [--method GET|PUT] [--expires-in <seconds>]
|
|
22
|
+
[--size <bytes> | --file <path>] [--content-type <type>]
|
|
23
|
+
|
|
24
|
+
--method GET (default) for a download or share link, PUT to upload.
|
|
25
|
+
--size Required for PUT. Signed into the URL, so an upload of any
|
|
26
|
+
other size is refused.
|
|
27
|
+
--file Read the size from a local file instead of passing --size.
|
|
28
|
+
--expires-in Seconds. Capped by the server; the output reports what was granted.`;
|
|
29
|
+
|
|
30
|
+
interface PresignResponse {
|
|
31
|
+
project_id: string;
|
|
32
|
+
key: string;
|
|
33
|
+
method: string;
|
|
34
|
+
url: string;
|
|
35
|
+
expires_in: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function flagValue(argv: string[], name: string): string | undefined {
|
|
39
|
+
const index = argv.indexOf(name);
|
|
40
|
+
if (index === -1) return undefined;
|
|
41
|
+
const value = argv[index + 1];
|
|
42
|
+
if (value === undefined || value.startsWith("--")) die(`${name} needs a value.\n\n${USAGE}`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function storageCommand(argv: string[], json: boolean): Promise<void> {
|
|
47
|
+
const [subcommand, ...rest] = argv;
|
|
48
|
+
if (subcommand !== "presign") die(USAGE);
|
|
49
|
+
|
|
50
|
+
const [slug, key] = rest;
|
|
51
|
+
if (!slug || !key) die(USAGE);
|
|
52
|
+
|
|
53
|
+
const project = await findProjectBySlug(slug);
|
|
54
|
+
if (!project) die(`No app named "${slug}".`);
|
|
55
|
+
|
|
56
|
+
const method = (flagValue(rest, "--method") ?? "GET").toUpperCase();
|
|
57
|
+
if (method !== "GET" && method !== "PUT") die(`--method must be GET or PUT.\n\n${USAGE}`);
|
|
58
|
+
|
|
59
|
+
const body: Record<string, unknown> = { key, method };
|
|
60
|
+
|
|
61
|
+
const expiresIn = flagValue(rest, "--expires-in");
|
|
62
|
+
if (expiresIn !== undefined) {
|
|
63
|
+
const seconds = Number(expiresIn);
|
|
64
|
+
if (!Number.isInteger(seconds) || seconds <= 0) die("--expires-in must be a whole number of seconds.");
|
|
65
|
+
body.expires_in = seconds;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const contentType = flagValue(rest, "--content-type");
|
|
69
|
+
if (contentType) body.content_type = contentType;
|
|
70
|
+
|
|
71
|
+
if (method === "PUT") {
|
|
72
|
+
const size = flagValue(rest, "--size");
|
|
73
|
+
const file = flagValue(rest, "--file");
|
|
74
|
+
if (size !== undefined && file !== undefined) die("Pass --size or --file, not both.");
|
|
75
|
+
if (size !== undefined) {
|
|
76
|
+
const bytes = Number(size);
|
|
77
|
+
if (!Number.isInteger(bytes) || bytes < 0) die("--size must be a whole number of bytes.");
|
|
78
|
+
body.content_length = bytes;
|
|
79
|
+
} else if (file !== undefined) {
|
|
80
|
+
try {
|
|
81
|
+
body.content_length = statSync(file).size;
|
|
82
|
+
} catch {
|
|
83
|
+
die(`Could not read "${file}".`);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
die(`A PUT needs --size or --file: the size is signed into the URL.\n\n${USAGE}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await apiFetch<PresignResponse>(
|
|
91
|
+
`/api/v1/projects/${project.id}/storage/presign`,
|
|
92
|
+
{ method: "POST", body: JSON.stringify(body) },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (json) {
|
|
96
|
+
printJson(result);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(result.url);
|
|
101
|
+
console.error("");
|
|
102
|
+
console.error(`${result.method} ${result.key} — valid for ${result.expires_in}s.`);
|
|
103
|
+
if (result.method === "GET") {
|
|
104
|
+
console.error("Anybody with this link can download the file until it expires.");
|
|
105
|
+
} else {
|
|
106
|
+
console.error(`Upload with: curl -X PUT --upload-file <file> "<url>"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* few that need subcommands (`secrets`, `db`, `mcp`) do their own
|
|
6
6
|
* second-level switch — no subcommand ambiguity worth a parser library yet.
|
|
7
7
|
*/
|
|
8
|
+
import { bundleCommand } from "./commands/bundle";
|
|
8
9
|
import { dbCommand } from "./commands/db";
|
|
9
10
|
import { deployCommand } from "./commands/deploy";
|
|
10
11
|
import { initCommand } from "./commands/init";
|
|
@@ -15,6 +16,7 @@ import { openCommand } from "./commands/open";
|
|
|
15
16
|
import { projectsCommand } from "./commands/projects";
|
|
16
17
|
import { runsCommand } from "./commands/runs";
|
|
17
18
|
import { secretsCommand } from "./commands/secrets";
|
|
19
|
+
import { storageCommand } from "./commands/storage";
|
|
18
20
|
import { statusCommand } from "./commands/status";
|
|
19
21
|
import { whoamiCommand } from "./commands/whoami";
|
|
20
22
|
import { AuthRequiredError, ApiError } from "./lib/api";
|
|
@@ -33,8 +35,10 @@ Usage:
|
|
|
33
35
|
svcloud init Connect a local repo as a new app
|
|
34
36
|
svcloud runs <app> Show or watch an app's provisioning run
|
|
35
37
|
svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
|
|
38
|
+
svcloud storage presign Get a direct URL for one file in an app's storage
|
|
36
39
|
svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
|
|
37
40
|
svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
|
|
41
|
+
svcloud bundle <cmd> Keep an app's starter current (status/update/done)
|
|
38
42
|
svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
|
|
39
43
|
svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
|
|
40
44
|
svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
|
|
@@ -69,6 +73,9 @@ async function main(): Promise<void> {
|
|
|
69
73
|
case "logout":
|
|
70
74
|
await logoutCommand();
|
|
71
75
|
return;
|
|
76
|
+
case "bundle":
|
|
77
|
+
await bundleCommand(rest, json);
|
|
78
|
+
return;
|
|
72
79
|
case "whoami":
|
|
73
80
|
await whoamiCommand(json);
|
|
74
81
|
return;
|
|
@@ -93,6 +100,9 @@ async function main(): Promise<void> {
|
|
|
93
100
|
case "secrets":
|
|
94
101
|
await secretsCommand(rest, json);
|
|
95
102
|
return;
|
|
103
|
+
case "storage":
|
|
104
|
+
await storageCommand(rest, json);
|
|
105
|
+
return;
|
|
96
106
|
case "db":
|
|
97
107
|
await dbCommand(rest, json);
|
|
98
108
|
return;
|
package/src/lib/api.ts
CHANGED
|
@@ -169,8 +169,28 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
|
|
|
169
169
|
// A non-JSON error body (e.g. a 502 in front of the Worker) still surfaces as a plain ApiError below.
|
|
170
170
|
}
|
|
171
171
|
if (res.status === 401 && !options.unauthenticated) {
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// Deliberately NOT clearTokens(). Reaching here means the forced
|
|
173
|
+
// refresh above SUCCEEDED — cloud-api accepted this session's refresh
|
|
174
|
+
// token — and then rejected the access token it had just minted. A
|
|
175
|
+
// live grant is not a dead session, so destroying the stored tokens is
|
|
176
|
+
// the wrong response: the likely causes are a token held against a
|
|
177
|
+
// DIFFERENT deployment than the one being called, or a transient
|
|
178
|
+
// server-side rejection, and neither is fixed by making the user sign
|
|
179
|
+
// in again from scratch. `refreshRequest` already clears the store on
|
|
180
|
+
// the one signal that does mean the grant is gone (the refresh itself
|
|
181
|
+
// being refused), and that stays the only path that wipes it.
|
|
182
|
+
//
|
|
183
|
+
// The hint names SVCLOUD_API_URL only when it is actually set: staging
|
|
184
|
+
// is ours, and a real user must never be pointed at a variable that
|
|
185
|
+
// plays no part in their setup.
|
|
186
|
+
const misdirected = process.env.SVCLOUD_API_URL
|
|
187
|
+
? ` The CLI is pointed at ${apiBaseUrl()} — check SVCLOUD_API_URL if that is not what you meant.`
|
|
188
|
+
: "";
|
|
189
|
+
throw new ApiError(
|
|
190
|
+
`Signed in, but ${apiBaseUrl()} rejected this session.${misdirected} Run \`svcloud login\` to sign in against it.`,
|
|
191
|
+
envelope?.error.code ?? "unauthenticated",
|
|
192
|
+
401,
|
|
193
|
+
);
|
|
174
194
|
}
|
|
175
195
|
throw new ApiError(
|
|
176
196
|
envelope?.error.message ?? `Request failed (${res.status}).`,
|
package/src/lib/config.ts
CHANGED
|
@@ -1,19 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Constants shared by every command. `SVCLOUD_API_URL` is the one env
|
|
3
|
-
* override, for pointing the CLI at a
|
|
4
|
-
*
|
|
3
|
+
* override, for pointing the CLI at a deployment that is not production — a
|
|
4
|
+
* local `wrangler dev` instance while building it, or our own staging
|
|
5
|
+
* environment. It is deliberately NOT a flag: staging is ours, not the
|
|
6
|
+
* product's, so it appears in no `--help` output and no README, and a real
|
|
7
|
+
* user's CLI behaves as though it does not exist.
|
|
5
8
|
*/
|
|
6
9
|
import { createRequire } from "node:module";
|
|
7
10
|
|
|
8
11
|
export const CLIENT_ID = "svcloud-cli";
|
|
9
12
|
|
|
13
|
+
/** Service/account names the credential store (lib/credentials.ts) files this CLI's token set under. */
|
|
14
|
+
export const CREDENTIAL_SERVICE = "svcloud-cli";
|
|
15
|
+
|
|
16
|
+
export const PRODUCTION_API_URL = "https://api.cloud.sv-academy.org";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The API this process talks to, normalized.
|
|
20
|
+
*
|
|
21
|
+
* Normalizing rather than returning the raw string is load-bearing: the value
|
|
22
|
+
* is interpolated straight into `fetch()` (`lib/api.ts`) and parsed by
|
|
23
|
+
* `credentialAccount()` below, and a host with no scheme — the shape a person
|
|
24
|
+
* naturally types — produces `TypeError: Failed to parse URL from
|
|
25
|
+
* api.cloud-staging.sv-academy.org/api/v1/...` several frames from the actual
|
|
26
|
+
* mistake. So a scheme-less value gets `https://`, and anything still
|
|
27
|
+
* unusable after that throws a sentence naming the variable instead of
|
|
28
|
+
* surfacing as a URL parser error.
|
|
29
|
+
*
|
|
30
|
+
* Failing loudly here also closes a real footgun in `credentialAccount()`: it
|
|
31
|
+
* used to fall back to the production slot for a value it could not parse,
|
|
32
|
+
* which is the one slot a non-production token must never land in.
|
|
33
|
+
*/
|
|
10
34
|
export function apiBaseUrl(): string {
|
|
11
|
-
|
|
35
|
+
const raw = process.env.SVCLOUD_API_URL?.trim();
|
|
36
|
+
if (!raw) return PRODUCTION_API_URL;
|
|
37
|
+
|
|
38
|
+
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw) ? raw : `https://${raw}`;
|
|
39
|
+
let url: URL;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(candidate);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(`SVCLOUD_API_URL is not a valid URL: ${raw}`);
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
46
|
+
throw new Error(`SVCLOUD_API_URL must be an http(s) URL: ${raw}`);
|
|
47
|
+
}
|
|
48
|
+
if (!url.host) throw new Error(`SVCLOUD_API_URL has no host: ${raw}`);
|
|
49
|
+
|
|
50
|
+
// Every caller appends a path beginning with "/", so the base must not end
|
|
51
|
+
// with one — `${base}${path}` would otherwise produce a double slash.
|
|
52
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
12
53
|
}
|
|
13
54
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The credential-store slot this CLI's token set lives in, keyed by which API
|
|
57
|
+
* it belongs to. Production keeps the literal "default" it has always used,
|
|
58
|
+
* so no existing login is invalidated; anything else (a staging deployment, a
|
|
59
|
+
* local `wrangler dev`) gets its own slot named for the host.
|
|
60
|
+
*
|
|
61
|
+
* Without this, `SVCLOUD_API_URL=… svcloud login` silently OVERWRITES the
|
|
62
|
+
* production token — same service, same account, one entry — and the next
|
|
63
|
+
* plain `svcloud` command runs against production with a token minted by a
|
|
64
|
+
* different environment, which fails in a way that looks like a session
|
|
65
|
+
* expiry rather than a footgun.
|
|
66
|
+
*/
|
|
67
|
+
export function credentialAccount(): string {
|
|
68
|
+
const url = apiBaseUrl();
|
|
69
|
+
if (url === PRODUCTION_API_URL) return "default";
|
|
70
|
+
return new URL(url).host;
|
|
71
|
+
}
|
|
17
72
|
|
|
18
73
|
/** This installed CLI's own version, from package.json — surfaced by `svcloud mcp check`. */
|
|
19
74
|
export const CLI_VERSION: string = createRequire(import.meta.url)("../../package.json").version;
|
package/src/lib/credentials.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { promisify } from "node:util";
|
|
31
|
-
import {
|
|
31
|
+
import { credentialAccount, CREDENTIAL_SERVICE } from "./config";
|
|
32
32
|
|
|
33
33
|
const run = promisify(execFile);
|
|
34
34
|
|
|
@@ -41,7 +41,21 @@ export interface TokenSet {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const FALLBACK_DIR = join(homedir(), ".config", "svcloud");
|
|
44
|
-
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every store below is keyed by `credentialAccount()`, not by a constant, so
|
|
47
|
+
* a token minted against one API never lands in another's slot — see that
|
|
48
|
+
* function's header. Resolved per call rather than at import: the CLI reads
|
|
49
|
+
* `SVCLOUD_API_URL` from the environment, which a test (and a user) can
|
|
50
|
+
* change between calls in one process.
|
|
51
|
+
*/
|
|
52
|
+
function fallbackFile(): string {
|
|
53
|
+
const account = credentialAccount();
|
|
54
|
+
return join(
|
|
55
|
+
FALLBACK_DIR,
|
|
56
|
+
account === "default" ? "credentials.json" : `credentials-${account}.json`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
45
59
|
|
|
46
60
|
async function macosSave(json: string): Promise<void> {
|
|
47
61
|
// -U: update in place if an entry already exists, so a re-login doesn't
|
|
@@ -49,7 +63,7 @@ async function macosSave(json: string): Promise<void> {
|
|
|
49
63
|
await run("security", [
|
|
50
64
|
"add-generic-password",
|
|
51
65
|
"-a",
|
|
52
|
-
|
|
66
|
+
credentialAccount(),
|
|
53
67
|
"-s",
|
|
54
68
|
CREDENTIAL_SERVICE,
|
|
55
69
|
"-w",
|
|
@@ -63,7 +77,7 @@ async function macosLoad(): Promise<string | undefined> {
|
|
|
63
77
|
const { stdout } = await run("security", [
|
|
64
78
|
"find-generic-password",
|
|
65
79
|
"-a",
|
|
66
|
-
|
|
80
|
+
credentialAccount(),
|
|
67
81
|
"-s",
|
|
68
82
|
CREDENTIAL_SERVICE,
|
|
69
83
|
"-w",
|
|
@@ -78,7 +92,7 @@ async function macosClear(): Promise<void> {
|
|
|
78
92
|
await run("security", [
|
|
79
93
|
"delete-generic-password",
|
|
80
94
|
"-a",
|
|
81
|
-
|
|
95
|
+
credentialAccount(),
|
|
82
96
|
"-s",
|
|
83
97
|
CREDENTIAL_SERVICE,
|
|
84
98
|
]).catch(() => {
|
|
@@ -96,7 +110,7 @@ async function linuxSave(json: string): Promise<void> {
|
|
|
96
110
|
"service",
|
|
97
111
|
CREDENTIAL_SERVICE,
|
|
98
112
|
"account",
|
|
99
|
-
|
|
113
|
+
credentialAccount(),
|
|
100
114
|
]);
|
|
101
115
|
pending.child.stdin?.end(json);
|
|
102
116
|
await pending;
|
|
@@ -109,7 +123,7 @@ async function linuxLoad(): Promise<string | undefined> {
|
|
|
109
123
|
"service",
|
|
110
124
|
CREDENTIAL_SERVICE,
|
|
111
125
|
"account",
|
|
112
|
-
|
|
126
|
+
credentialAccount(),
|
|
113
127
|
]);
|
|
114
128
|
return stdout.replace(/\n$/, "") || undefined;
|
|
115
129
|
} catch {
|
|
@@ -123,7 +137,7 @@ async function linuxClear(): Promise<void> {
|
|
|
123
137
|
"service",
|
|
124
138
|
CREDENTIAL_SERVICE,
|
|
125
139
|
"account",
|
|
126
|
-
|
|
140
|
+
credentialAccount(),
|
|
127
141
|
]).catch(() => {});
|
|
128
142
|
}
|
|
129
143
|
|
|
@@ -135,7 +149,9 @@ async function linuxClear(): Promise<void> {
|
|
|
135
149
|
* macOS); any failure here falls back to the plaintext file below rather
|
|
136
150
|
* than surfacing a build-only error to a novice.
|
|
137
151
|
*/
|
|
138
|
-
|
|
152
|
+
function psTarget(): string {
|
|
153
|
+
return `${CREDENTIAL_SERVICE}/${credentialAccount()}`;
|
|
154
|
+
}
|
|
139
155
|
|
|
140
156
|
const PS_PROLOGUE = `
|
|
141
157
|
Add-Type -Namespace SvCloud -Name Cred -MemberDefinition @'
|
|
@@ -164,8 +180,8 @@ $bytes = [Convert]::FromBase64String("${bytes.toString("base64")}")
|
|
|
164
180
|
$blob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
|
|
165
181
|
[Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length)
|
|
166
182
|
$cred = New-Object SvCloud.Cred+CREDENTIAL
|
|
167
|
-
$cred.Type = 1; $cred.TargetName = "${
|
|
168
|
-
$cred.CredentialBlob = $blob; $cred.Persist = 2; $cred.UserName = "${
|
|
183
|
+
$cred.Type = 1; $cred.TargetName = "${psTarget()}"; $cred.CredentialBlobSize = $bytes.Length
|
|
184
|
+
$cred.CredentialBlob = $blob; $cred.Persist = 2; $cred.UserName = "${credentialAccount()}"
|
|
169
185
|
if (-not [SvCloud.Cred]::CredWrite([ref]$cred, 0)) { throw "CredWrite failed" }
|
|
170
186
|
[Runtime.InteropServices.Marshal]::FreeHGlobal($blob)`;
|
|
171
187
|
await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
|
|
@@ -174,7 +190,7 @@ if (-not [SvCloud.Cred]::CredWrite([ref]$cred, 0)) { throw "CredWrite failed" }
|
|
|
174
190
|
async function windowsLoad(): Promise<string | undefined> {
|
|
175
191
|
const script = `${PS_PROLOGUE}
|
|
176
192
|
$ptr = [IntPtr]::Zero
|
|
177
|
-
if (-not [SvCloud.Cred]::CredRead("${
|
|
193
|
+
if (-not [SvCloud.Cred]::CredRead("${psTarget()}", 1, 0, [ref]$ptr)) { exit 1 }
|
|
178
194
|
$cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [Type][SvCloud.Cred+CREDENTIAL])
|
|
179
195
|
$bytes = New-Object byte[] $cred.CredentialBlobSize
|
|
180
196
|
[Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
|
|
@@ -190,15 +206,15 @@ $bytes = New-Object byte[] $cred.CredentialBlobSize
|
|
|
190
206
|
}
|
|
191
207
|
|
|
192
208
|
async function windowsClear(): Promise<void> {
|
|
193
|
-
const script = `${PS_PROLOGUE}[SvCloud.Cred]::CredDelete("${
|
|
209
|
+
const script = `${PS_PROLOGUE}[SvCloud.Cred]::CredDelete("${psTarget()}", 1, 0) | Out-Null`;
|
|
194
210
|
await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]).catch(() => {});
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
async function fallbackSave(json: string): Promise<void> {
|
|
198
214
|
await mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 });
|
|
199
|
-
await writeFile(
|
|
215
|
+
await writeFile(fallbackFile(), json, { mode: 0o600 });
|
|
200
216
|
console.error(
|
|
201
|
-
`Note: your SV Cloud sign-in is stored in plain text at ${
|
|
217
|
+
`Note: your SV Cloud sign-in is stored in plain text at ${fallbackFile()} ` +
|
|
202
218
|
"because no system keychain was available. Anyone who can read that " +
|
|
203
219
|
"file can act as you until you run `svcloud logout`.",
|
|
204
220
|
);
|
|
@@ -206,14 +222,14 @@ async function fallbackSave(json: string): Promise<void> {
|
|
|
206
222
|
|
|
207
223
|
async function fallbackLoad(): Promise<string | undefined> {
|
|
208
224
|
try {
|
|
209
|
-
return await readFile(
|
|
225
|
+
return await readFile(fallbackFile(), "utf-8");
|
|
210
226
|
} catch {
|
|
211
227
|
return undefined;
|
|
212
228
|
}
|
|
213
229
|
}
|
|
214
230
|
|
|
215
231
|
async function fallbackClear(): Promise<void> {
|
|
216
|
-
await rm(
|
|
232
|
+
await rm(fallbackFile(), { force: true });
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
async function platformSave(json: string): Promise<boolean> {
|
package/src/lib/harnesses.ts
CHANGED
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* Codex's entry is carried over from the README as it stood before this
|
|
15
15
|
* change (`~/.codex/config.json`, `mcpServers`) — not independently
|
|
16
16
|
* re-verified here; only OpenCode's shape was reported wrong.
|
|
17
|
+
*
|
|
18
|
+
* `buildEntry` takes the command and args rather than hardcoding
|
|
19
|
+
* `"svcloud" ["mcp"]`, because what is spawnable depends on the machine:
|
|
20
|
+
* `lib/self.ts` resolves the bare name against PATH and falls back to this
|
|
21
|
+
* install's own absolute paths when it does not resolve. `isWellFormed`
|
|
22
|
+
* deliberately does NOT check the command's value, only its shape — an
|
|
23
|
+
* absolute-path entry a previous `mcp setup` wrote is correct, and so is a
|
|
24
|
+
* bare-name one somebody wrote by hand.
|
|
17
25
|
*/
|
|
18
26
|
import { readFile } from "node:fs/promises";
|
|
19
27
|
import { homedir } from "node:os";
|
|
@@ -25,6 +33,10 @@ export interface HarnessEntry {
|
|
|
25
33
|
command: string | string[];
|
|
26
34
|
args?: string[];
|
|
27
35
|
type?: string;
|
|
36
|
+
/** Claude-style harnesses. Present only when there are variables to pass. */
|
|
37
|
+
env?: Record<string, string>;
|
|
38
|
+
/** OpenCode's spelling of the same thing — verified against opencode.ai/docs/mcp-servers/. */
|
|
39
|
+
environment?: Record<string, string>;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
export interface Harness {
|
|
@@ -35,8 +47,13 @@ export interface Harness {
|
|
|
35
47
|
configPath: (cwd: string) => string;
|
|
36
48
|
/** The top-level object key an entry for svcloud lives under. */
|
|
37
49
|
serversKey: string;
|
|
38
|
-
/**
|
|
39
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Build this harness's shape of the svcloud server entry. `env` is omitted
|
|
52
|
+
* far more often than not — see `mcp-setup.ts`'s `inheritedEnv` — and when
|
|
53
|
+
* it is, the entry written must be byte-identical to what this table
|
|
54
|
+
* produced before environment passthrough existed.
|
|
55
|
+
*/
|
|
56
|
+
buildEntry: (command: string, args: string[], env?: Record<string, string>) => HarnessEntry;
|
|
40
57
|
/** True if an existing entry (already parsed as JSON) has this harness's expected shape. */
|
|
41
58
|
isWellFormed: (entry: unknown) => boolean;
|
|
42
59
|
}
|
|
@@ -45,7 +62,11 @@ function isStringArray(v: unknown): v is string[] {
|
|
|
45
62
|
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
const claudeStyleEntry = (
|
|
65
|
+
const claudeStyleEntry = (
|
|
66
|
+
command: string,
|
|
67
|
+
args: string[],
|
|
68
|
+
env?: Record<string, string>,
|
|
69
|
+
): HarnessEntry => ({ command, args, ...(env ? { env } : {}) });
|
|
49
70
|
const claudeStyleWellFormed = (entry: unknown): boolean =>
|
|
50
71
|
typeof entry === "object" &&
|
|
51
72
|
entry !== null &&
|
|
@@ -104,7 +125,15 @@ export const HARNESSES: Harness[] = [
|
|
|
104
125
|
scope: "global",
|
|
105
126
|
configPath: () => join(homedir(), ".config", "opencode", "opencode.json"),
|
|
106
127
|
serversKey: "mcp",
|
|
107
|
-
|
|
128
|
+
// OpenCode spells the environment block `environment`, not `env` — the
|
|
129
|
+
// one shape difference in this table, verified against
|
|
130
|
+
// https://opencode.ai/docs/mcp-servers/ the same way its file path and
|
|
131
|
+
// `mcp` key were.
|
|
132
|
+
buildEntry: (command, args, env) => ({
|
|
133
|
+
type: "local",
|
|
134
|
+
command: [command, ...args],
|
|
135
|
+
...(env ? { environment: env } : {}),
|
|
136
|
+
}),
|
|
108
137
|
isWellFormed: (entry: unknown): boolean =>
|
|
109
138
|
typeof entry === "object" &&
|
|
110
139
|
entry !== null &&
|
|
@@ -118,12 +147,42 @@ export function findHarness(id: string): Harness | undefined {
|
|
|
118
147
|
return HARNESSES.find((h) => h.id === id);
|
|
119
148
|
}
|
|
120
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The API deployment an already-written entry spawns the bridge against, or
|
|
152
|
+
* undefined for the default (production). Reads both spellings of the
|
|
153
|
+
* environment block, so one caller covers the whole table.
|
|
154
|
+
*
|
|
155
|
+
* Kept out of `isWellFormed`, which checks an entry's SHAPE and deliberately
|
|
156
|
+
* not its values (see this file's header): an entry naming a different
|
|
157
|
+
* deployment is well-formed, it is just pointed somewhere else, and telling
|
|
158
|
+
* those two cases apart is what lets `mcp setup` correct the second without
|
|
159
|
+
* demanding `--force` for the first.
|
|
160
|
+
*/
|
|
161
|
+
export function readEntryApiUrl(entry: unknown): string | undefined {
|
|
162
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
163
|
+
const record = entry as Record<string, unknown>;
|
|
164
|
+
for (const key of ["env", "environment"]) {
|
|
165
|
+
const block = record[key];
|
|
166
|
+
if (typeof block !== "object" || block === null) continue;
|
|
167
|
+
const value = (block as Record<string, unknown>).SVCLOUD_API_URL;
|
|
168
|
+
if (typeof value === "string" && value) return value;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
121
173
|
export type HarnessStatus =
|
|
122
174
|
| { state: "not_found"; path: string }
|
|
123
175
|
| { state: "unreadable"; path: string; detail: string }
|
|
124
176
|
| { state: "missing_entry"; path: string }
|
|
125
177
|
| { state: "wrong_shape"; path: string }
|
|
126
|
-
|
|
178
|
+
/**
|
|
179
|
+
* `apiUrl` is the deployment the recorded entry spawns the bridge against,
|
|
180
|
+
* undefined meaning the default (production). Carried on the status so
|
|
181
|
+
* `mcp check` can report an entry that is correctly shaped but pointed
|
|
182
|
+
* somewhere other than the shell doing the checking — the one failure this
|
|
183
|
+
* command exists to make visible that shape alone cannot show.
|
|
184
|
+
*/
|
|
185
|
+
| { state: "ok"; path: string; apiUrl?: string };
|
|
127
186
|
|
|
128
187
|
/** Reads and inspects a harness's config file — never throws. */
|
|
129
188
|
export async function inspectHarness(harness: Harness, cwd: string): Promise<HarnessStatus> {
|
|
@@ -144,5 +203,5 @@ export async function inspectHarness(harness: Harness, cwd: string): Promise<Har
|
|
|
144
203
|
const entry = (servers as Record<string, unknown> | undefined)?.svcloud;
|
|
145
204
|
if (entry === undefined) return { state: "missing_entry", path };
|
|
146
205
|
if (!harness.isWellFormed(entry)) return { state: "wrong_shape", path };
|
|
147
|
-
return { state: "ok", path };
|
|
206
|
+
return { state: "ok", path, apiUrl: readEntryApiUrl(entry) };
|
|
148
207
|
}
|
package/src/lib/self.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to spawn THIS CLI again, from a config file another program reads.
|
|
3
|
+
*
|
|
4
|
+
* `svcloud mcp setup` writes a harness config whose `command` is whatever
|
|
5
|
+
* that harness will later exec. Writing the bare name `svcloud` is right
|
|
6
|
+
* whenever the npm global bin directory is on PATH, and silently useless
|
|
7
|
+
* whenever it is not: the harness spawns, fails with ENOENT, and shows the
|
|
8
|
+
* owner an MCP server that is simply never there. That was a real report
|
|
9
|
+
* (2026-09-05) from someone who had run `npm install -g svcloud`
|
|
10
|
+
* successfully and still had to invoke `npx svcloud` for everything,
|
|
11
|
+
* because their shell's PATH did not include `$(npm prefix -g)/bin`.
|
|
12
|
+
*
|
|
13
|
+
* So the command is resolved rather than assumed. If a `svcloud`
|
|
14
|
+
* executable is genuinely reachable on PATH, that is what gets written —
|
|
15
|
+
* it survives a CLI upgrade that moves the install directory. If it is
|
|
16
|
+
* not, the config gets this process's own absolute paths instead, which
|
|
17
|
+
* works no matter what the harness's PATH looks like. Node's own binary is
|
|
18
|
+
* used rather than the npm shim: the shim's exec bit and its `.cmd`
|
|
19
|
+
* wrapper on Windows are two more things that can be wrong, and
|
|
20
|
+
* `process.execPath` is neither.
|
|
21
|
+
*/
|
|
22
|
+
import { accessSync, constants } from "node:fs";
|
|
23
|
+
import { delimiter, dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
/** `bin/svcloud.js` in this installed package, absolute. */
|
|
27
|
+
export function ownBinPath(): string {
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
return join(here, "..", "..", "bin", "svcloud.js");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a bare `svcloud` resolves to an executable on this PATH.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately not `which`/`where`: spawning a shell to answer a question
|
|
36
|
+
* about the environment we are already in is slower and less predictable
|
|
37
|
+
* than reading PATH ourselves. On Windows a bare name is resolved against
|
|
38
|
+
* PATHEXT, so the shim's real filename there is `svcloud.cmd`.
|
|
39
|
+
*/
|
|
40
|
+
export function isOnPath(name = "svcloud", env: NodeJS.ProcessEnv = process.env): boolean {
|
|
41
|
+
const raw = env.PATH ?? env.Path ?? "";
|
|
42
|
+
if (!raw) return false;
|
|
43
|
+
const suffixes = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
44
|
+
for (const dir of raw.split(delimiter)) {
|
|
45
|
+
if (!dir) continue;
|
|
46
|
+
for (const suffix of suffixes) {
|
|
47
|
+
try {
|
|
48
|
+
accessSync(join(dir, `${name}${suffix}`), constants.X_OK);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
/* Not here; keep looking. */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BridgeCommand {
|
|
59
|
+
command: string;
|
|
60
|
+
args: string[];
|
|
61
|
+
/** True when the bare name resolved, so the config is portable. */
|
|
62
|
+
onPath: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `command` + `args` a harness config should use to run `svcloud mcp`. */
|
|
66
|
+
export function bridgeCommand(): BridgeCommand {
|
|
67
|
+
if (isOnPath()) return { command: "svcloud", args: ["mcp"], onPath: true };
|
|
68
|
+
return { command: process.execPath, args: [ownBinPath(), "mcp"], onPath: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What to tell somebody whose npm global bin is not on PATH.
|
|
73
|
+
*
|
|
74
|
+
* The directory is derived from where this file actually is rather than
|
|
75
|
+
* from `npm prefix -g`, so the sentence names the path that would really
|
|
76
|
+
* work for this install, including inside a version manager's per-version
|
|
77
|
+
* prefix.
|
|
78
|
+
*/
|
|
79
|
+
export function pathHint(): string {
|
|
80
|
+
const binDir = dirname(ownBinPath());
|
|
81
|
+
return (
|
|
82
|
+
`\`svcloud\` is not on your PATH, so your shell can only reach it through \`npx\`.\n` +
|
|
83
|
+
`Add it with:\n` +
|
|
84
|
+
` echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n` +
|
|
85
|
+
`(use ~/.bashrc on Linux). This install's own binary is at:\n` +
|
|
86
|
+
` ${join(binDir, "svcloud.js")}`
|
|
87
|
+
);
|
|
88
|
+
}
|