ninepm 0.32.0 → 0.36.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/index.d.ts +27 -3
- package/dist/index.js +307 -27
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface DeployOptions {
|
|
|
11
11
|
environment?: string;
|
|
12
12
|
withDatabase?: boolean;
|
|
13
13
|
withAuth?: boolean;
|
|
14
|
+
withEmail?: boolean;
|
|
14
15
|
databaseName?: string;
|
|
15
16
|
bindingName?: string;
|
|
16
17
|
runtime?: AppRuntime;
|
|
@@ -112,7 +113,7 @@ export interface CreatedServiceToken {
|
|
|
112
113
|
clientSecret: string;
|
|
113
114
|
}
|
|
114
115
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
115
|
-
export declare const CLI_VERSION = "0.
|
|
116
|
+
export declare const CLI_VERSION = "0.36.0";
|
|
116
117
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
117
118
|
export interface CredentialDiagnostics {
|
|
118
119
|
platform: NodeJS.Platform;
|
|
@@ -183,6 +184,9 @@ export declare class DeployError extends Error {
|
|
|
183
184
|
constructor(message: string, slug: string | undefined);
|
|
184
185
|
}
|
|
185
186
|
export declare function deployDirectory(initialOptions: DeployOptions): Promise<DeployResult>;
|
|
187
|
+
export type ApiReachability = "reachable" | "blocked";
|
|
188
|
+
export declare function probeApiReachability(apiUrl: string, fetchFn: typeof fetch, timeoutMs?: number): Promise<ApiReachability>;
|
|
189
|
+
export declare function apiUnreachableGuidance(apiUrl: string): string;
|
|
186
190
|
export declare function checkDeployDirectory(options: DeployOptions): Promise<DeployCheckResult>;
|
|
187
191
|
export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
|
|
188
192
|
export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
|
|
@@ -240,12 +244,19 @@ export interface ListSitesResult {
|
|
|
240
244
|
}
|
|
241
245
|
export declare function listSites(options: ApiOptions): Promise<ListSitesResult>;
|
|
242
246
|
export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
|
|
247
|
+
export interface DeviceAuthFeedbackOptions {
|
|
248
|
+
progressStream?: NodeJS.WriteStream;
|
|
249
|
+
openBrowserFn?: (url: string) => BrowserOpenResult;
|
|
250
|
+
storeCredentialFn?: (token: string, options: {
|
|
251
|
+
allowFileFallback?: boolean | undefined;
|
|
252
|
+
}) => string;
|
|
253
|
+
}
|
|
243
254
|
export declare function loginDevice(options: {
|
|
244
255
|
apiUrl: string;
|
|
245
256
|
fetchFn?: FetchLike;
|
|
246
257
|
machineName?: string;
|
|
247
258
|
allowFileCredentialStore?: boolean;
|
|
248
|
-
}): Promise<{
|
|
259
|
+
} & DeviceAuthFeedbackOptions): Promise<{
|
|
249
260
|
email?: string;
|
|
250
261
|
storage: string;
|
|
251
262
|
}>;
|
|
@@ -254,10 +265,11 @@ export declare function signupDevice(options: {
|
|
|
254
265
|
fetchFn?: FetchLike;
|
|
255
266
|
machineName?: string;
|
|
256
267
|
allowFileCredentialStore?: boolean;
|
|
257
|
-
}): Promise<{
|
|
268
|
+
} & DeviceAuthFeedbackOptions): Promise<{
|
|
258
269
|
email?: string;
|
|
259
270
|
storage: string;
|
|
260
271
|
}>;
|
|
272
|
+
export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
|
|
261
273
|
export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
|
|
262
274
|
export declare function putManagedFile(options: FilePutOptions): Promise<ManagedFilePutResult>;
|
|
263
275
|
export declare function getManagedFile(options: FileGetOptions): Promise<{
|
|
@@ -290,6 +302,11 @@ export interface UploadProgressReporter {
|
|
|
290
302
|
stop(): void;
|
|
291
303
|
}
|
|
292
304
|
export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
|
|
305
|
+
export interface DeviceAuthProgressReporter {
|
|
306
|
+
tick(remainingMs: number): void;
|
|
307
|
+
stop(): void;
|
|
308
|
+
}
|
|
309
|
+
export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
|
|
293
310
|
type CliCommand = {
|
|
294
311
|
kind: "deploy";
|
|
295
312
|
options: DeployOptions;
|
|
@@ -462,6 +479,13 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
|
|
|
462
479
|
kind: "secret" | "var";
|
|
463
480
|
}>;
|
|
464
481
|
export declare function credentialDiagnostics(): CredentialDiagnostics;
|
|
482
|
+
export type BrowserOpenResult = "opened" | "failed" | "unknown";
|
|
483
|
+
export declare function classifyBrowserOpen(input: {
|
|
484
|
+
platform: string;
|
|
485
|
+
status: number | null;
|
|
486
|
+
error?: unknown;
|
|
487
|
+
}): BrowserOpenResult;
|
|
488
|
+
export declare function hasNoGraphicalDisplay(platform: string, env: Record<string, string | undefined>): boolean;
|
|
465
489
|
export declare class UsageError extends Error {
|
|
466
490
|
}
|
|
467
491
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
|
|
|
12
12
|
import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
|
|
13
13
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
14
14
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
15
|
-
export const CLI_VERSION = "0.
|
|
15
|
+
export const CLI_VERSION = "0.36.0";
|
|
16
16
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
17
17
|
const TOKEN_SERVICE = "9pm";
|
|
18
18
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -229,6 +229,44 @@ export async function deployDirectory(initialOptions) {
|
|
|
229
229
|
throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug);
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
+
// Cheap unauthenticated probe of the control-plane API before a deploy. The
|
|
233
|
+
// sandbox egress proxy blocks at CONNECT, so ANY HTTP response (even a 5xx)
|
|
234
|
+
// proves the host is reachable — only a thrown/aborted request signals a block.
|
|
235
|
+
// A tight timeout keeps the reachable path fast and the blocked path from
|
|
236
|
+
// hanging.
|
|
237
|
+
export async function probeApiReachability(apiUrl, fetchFn,
|
|
238
|
+
// Comfortably above /health tail latency so a slow-but-reachable link isn't
|
|
239
|
+
// aborted and misread as blocked; the reachable path returns well under this.
|
|
240
|
+
timeoutMs = 5000) {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
243
|
+
try {
|
|
244
|
+
await fetchFn(`${apiUrl}/health`, { method: "GET", signal: controller.signal });
|
|
245
|
+
return "reachable";
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return "blocked";
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Actionable message when the API host can't be reached — the two fixes for a
|
|
255
|
+
// default ("Trusted") sandbox, matching the #258 byte-channel design.
|
|
256
|
+
export function apiUnreachableGuidance(apiUrl) {
|
|
257
|
+
let host = apiUrl;
|
|
258
|
+
try {
|
|
259
|
+
host = new URL(apiUrl).host;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// keep the raw value if it isn't a full URL
|
|
263
|
+
}
|
|
264
|
+
return (`Can't reach ${host}. Most often a default ("Trusted") cloud sandbox is blocking the host, ` +
|
|
265
|
+
`but it can also be a transient network issue or a ${host} outage (check status if the fixes below don't help). ` +
|
|
266
|
+
`If it's the sandbox, fix it either way:\n` +
|
|
267
|
+
` 1. Add ${host} to your environment's Allowed domains (switch network access to Custom), then re-run.\n` +
|
|
268
|
+
` 2. Or deploy through the 9pm MCP connector — your agent can enable it and deploy with the 9pm tools, no network change needed.`);
|
|
269
|
+
}
|
|
232
270
|
async function runDeploy(initialOptions, slugHolder) {
|
|
233
271
|
const options = prepareFrameworkDeploy(initialOptions);
|
|
234
272
|
// #374: enrich network-level fetch rejections (EPIPE, header timeouts, …)
|
|
@@ -259,6 +297,9 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
259
297
|
if (options.withAuth) {
|
|
260
298
|
await enableEndUserAuth(fetchFn, apiUrl, options.token, project.id);
|
|
261
299
|
}
|
|
300
|
+
if (options.withEmail) {
|
|
301
|
+
await enableAppEmail(fetchFn, apiUrl, options.token, project.id);
|
|
302
|
+
}
|
|
262
303
|
const created = await createDeployment(fetchFn, apiUrl, options.token, project.id, {
|
|
263
304
|
files: [],
|
|
264
305
|
environment: options.environment ?? "production",
|
|
@@ -385,6 +426,9 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
385
426
|
if (options.withAuth) {
|
|
386
427
|
await enableEndUserAuth(fetchFn, apiUrl, options.token, project.id);
|
|
387
428
|
}
|
|
429
|
+
if (options.withEmail) {
|
|
430
|
+
await enableAppEmail(fetchFn, apiUrl, options.token, project.id);
|
|
431
|
+
}
|
|
388
432
|
const created = await createDeployment(fetchFn, apiUrl, options.token, project.id, {
|
|
389
433
|
files: manifest,
|
|
390
434
|
environment: options.environment ?? "production",
|
|
@@ -711,6 +755,12 @@ async function deviceAuthFlow(options) {
|
|
|
711
755
|
headers: { "content-type": "application/json" },
|
|
712
756
|
body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
|
|
713
757
|
});
|
|
758
|
+
// The control plane refuses a signup it knows can never be approved (#507) with
|
|
759
|
+
// a message written for the tester. Surface it alone and stop here, rather than
|
|
760
|
+
// opening a browser and polling for something that will never arrive.
|
|
761
|
+
if (!startResponse.ok) {
|
|
762
|
+
throw new Error(await apiErrorMessage(startResponse, `start device ${options.purpose}`));
|
|
763
|
+
}
|
|
714
764
|
const start = await expectJson(startResponse, `start device ${options.purpose}`);
|
|
715
765
|
const userCode = start.user_code ?? start.code;
|
|
716
766
|
const deviceCode = start.device_code;
|
|
@@ -719,29 +769,104 @@ async function deviceAuthFlow(options) {
|
|
|
719
769
|
}
|
|
720
770
|
console.log(`Open ${start.verification_uri}`);
|
|
721
771
|
console.log(`Device code: ${userCode}`);
|
|
722
|
-
|
|
772
|
+
// Everything new below goes to stderr (#509): stdout keeps exactly the two
|
|
773
|
+
// lines above plus the caller's unchanged result line, so anything already
|
|
774
|
+
// reading it is unaffected.
|
|
775
|
+
const progressStream = options.progressStream ?? process.stderr;
|
|
776
|
+
const browser = (options.openBrowserFn ?? openBrowser)(start.verification_uri);
|
|
777
|
+
if (browser === "failed") {
|
|
778
|
+
// Say it the moment it happens. A swallowed browser failure used to look
|
|
779
|
+
// exactly like a flow nobody had approved yet.
|
|
780
|
+
progressStream.write("Could not open a browser automatically.\n");
|
|
781
|
+
}
|
|
782
|
+
// Stated on every device auth, not only after a failure: the printed URL
|
|
783
|
+
// carries the code, so opening it anywhere finishes the flow. That recovery
|
|
784
|
+
// path has to be known before it is needed, not discovered by guesswork.
|
|
785
|
+
progressStream.write("Open the URL above in any browser — on this machine or another — to approve this device.\n");
|
|
723
786
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
724
787
|
const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
788
|
+
// stop() in a finally so no outcome — approval, denial, network throw,
|
|
789
|
+
// expiry — can leave the spinner timer alive or the line half-drawn.
|
|
790
|
+
const progress = createDeviceAuthProgressReporter(progressStream);
|
|
791
|
+
try {
|
|
792
|
+
while (Date.now() < deadline) {
|
|
793
|
+
// Tick before the sleep so the first feedback is immediate rather than
|
|
794
|
+
// one poll interval late. Remaining time is derived from the deadline the
|
|
795
|
+
// server's expires_in produced, never from a CLI-side constant.
|
|
796
|
+
progress.tick(deadline - Date.now());
|
|
797
|
+
await sleep(intervalMs);
|
|
798
|
+
const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
|
|
799
|
+
method: "POST",
|
|
800
|
+
headers: { "content-type": "application/json" },
|
|
801
|
+
body: JSON.stringify({ code: deviceCode }),
|
|
802
|
+
});
|
|
803
|
+
if (pollResponse.status === 400) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (pollResponse.status === 410) {
|
|
807
|
+
// The server distinguishes three terminal states behind this one status
|
|
808
|
+
// (expired / denied / consumed); the old branch collapsed them into one
|
|
809
|
+
// message and appended the raw JSON envelope. It also swallowed the
|
|
810
|
+
// actionable expiry guidance below: the server starts the clock before the
|
|
811
|
+
// client computes `deadline`, so a login left unapproved almost always ends
|
|
812
|
+
// HERE rather than by the loop running out — which made that guidance
|
|
813
|
+
// unreachable in the normal case (#514 review).
|
|
814
|
+
const state = await deviceTerminalState(pollResponse);
|
|
815
|
+
if (state === "denied") {
|
|
816
|
+
throw new Error(`Device ${options.purpose} was declined in the browser. ` +
|
|
817
|
+
`Run "9pm ${options.purpose}" again if that was not intended.`);
|
|
818
|
+
}
|
|
819
|
+
if (state === "consumed") {
|
|
820
|
+
throw new Error(`Device ${options.purpose} code ${userCode} was already used. ` +
|
|
821
|
+
`Run "9pm ${options.purpose}" again to get a fresh code.`);
|
|
822
|
+
}
|
|
823
|
+
// "expired", and anything unrecognised: expiry is what a 410 means here by
|
|
824
|
+
// default, and the guidance is useful either way.
|
|
825
|
+
throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
|
|
826
|
+
}
|
|
827
|
+
const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
|
|
828
|
+
// Retire the spinner the moment approval lands, BEFORE credential storage
|
|
829
|
+
// and the account lookup. storeCredential can fall back to plaintext and
|
|
830
|
+
// warn on stderr, and whoami is an awaited round trip — either would have
|
|
831
|
+
// its output appended to an unterminated spinner line, and the redraw timer
|
|
832
|
+
// could fire again underneath it, corrupting the tail of a *successful*
|
|
833
|
+
// login (#514 review). stop() is idempotent, so the finally below stays as
|
|
834
|
+
// the fallback for every other exit path.
|
|
835
|
+
progress.stop();
|
|
836
|
+
const storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
|
|
837
|
+
allowFileFallback: options.allowFileCredentialStore,
|
|
838
|
+
});
|
|
839
|
+
const me = await whoami({ apiUrl, token: poll.token, fetchFn });
|
|
840
|
+
return { email: me.user.email, storage };
|
|
738
841
|
}
|
|
739
|
-
const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
|
|
740
|
-
const storage = storeCredential(poll.token, { allowFileFallback: options.allowFileCredentialStore });
|
|
741
|
-
const me = await whoami({ apiUrl, token: poll.token, fetchFn });
|
|
742
|
-
return { email: me.user.email, storage };
|
|
743
842
|
}
|
|
744
|
-
|
|
843
|
+
finally {
|
|
844
|
+
progress.stop();
|
|
845
|
+
}
|
|
846
|
+
// Reached only when the client's own deadline elapses without the server having
|
|
847
|
+
// said anything terminal — rarer than the 410 route above, but it must say the
|
|
848
|
+
// same thing, so both share one message.
|
|
849
|
+
throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
|
|
850
|
+
}
|
|
851
|
+
// The actionable timeout guidance, shared by both routes out of an unapproved
|
|
852
|
+
// device flow (the server's 410 and the client's own deadline) so they can never
|
|
853
|
+
// drift into saying different things.
|
|
854
|
+
export function expiredDeviceAuthMessage(purpose, userCode, lifetimeMs, verificationUri) {
|
|
855
|
+
return (`Device ${purpose} expired: code ${userCode} was not approved within ${formatRemainingForCli(lifetimeMs)}. ` +
|
|
856
|
+
`Run "9pm ${purpose}" again, then open the printed URL in any browser — on this machine or another — to approve it. ` +
|
|
857
|
+
`If no page ever loaded, check that ${verificationUri} is reachable from this network.`);
|
|
858
|
+
}
|
|
859
|
+
// Which terminal state the control plane meant by its 410. Returns "" when the
|
|
860
|
+
// body is not the expected envelope, so callers fall back rather than guess.
|
|
861
|
+
async function deviceTerminalState(response) {
|
|
862
|
+
const text = await response.text();
|
|
863
|
+
try {
|
|
864
|
+
const parsed = JSON.parse(text);
|
|
865
|
+
return typeof parsed.state === "string" ? parsed.state : "";
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return "";
|
|
869
|
+
}
|
|
745
870
|
}
|
|
746
871
|
async function createProject(fetchFn, apiUrl, token, body) {
|
|
747
872
|
const response = await fetchFn(`${apiUrl}/v1/sites`, {
|
|
@@ -835,6 +960,14 @@ async function enableEndUserAuth(fetchFn, apiUrl, token, projectId) {
|
|
|
835
960
|
});
|
|
836
961
|
await expectJson(response, "enable end-user auth");
|
|
837
962
|
}
|
|
963
|
+
async function enableAppEmail(fetchFn, apiUrl, token, projectId) {
|
|
964
|
+
const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/email/enable`, {
|
|
965
|
+
method: "POST",
|
|
966
|
+
headers: jsonHeaders(token),
|
|
967
|
+
body: "{}",
|
|
968
|
+
});
|
|
969
|
+
await expectJson(response, "enable app email");
|
|
970
|
+
}
|
|
838
971
|
async function createDeployment(fetchFn, apiUrl, token, projectId, body) {
|
|
839
972
|
const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/deployments`, {
|
|
840
973
|
method: "POST",
|
|
@@ -1097,7 +1230,8 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
|
|
|
1097
1230
|
await dispatcher.close().catch(() => { });
|
|
1098
1231
|
}
|
|
1099
1232
|
}
|
|
1100
|
-
|
|
1233
|
+
// Shared by every in-place stderr reporter in this file; do not duplicate.
|
|
1234
|
+
const CLI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1101
1235
|
export function createUploadProgressReporter(stream = process.stderr) {
|
|
1102
1236
|
const isTty = stream.isTTY === true;
|
|
1103
1237
|
let timer;
|
|
@@ -1115,7 +1249,7 @@ export function createUploadProgressReporter(stream = process.stderr) {
|
|
|
1115
1249
|
const redraw = () => {
|
|
1116
1250
|
if (!last)
|
|
1117
1251
|
return;
|
|
1118
|
-
stream.write(`\r\x1b[K${
|
|
1252
|
+
stream.write(`\r\x1b[K${CLI_SPINNER_FRAMES[frame % CLI_SPINNER_FRAMES.length]} ${label(last)}`);
|
|
1119
1253
|
};
|
|
1120
1254
|
const stopTimer = () => {
|
|
1121
1255
|
if (timer) {
|
|
@@ -1186,6 +1320,72 @@ export function createUploadProgressReporter(stream = process.stderr) {
|
|
|
1186
1320
|
},
|
|
1187
1321
|
};
|
|
1188
1322
|
}
|
|
1323
|
+
// Off-TTY ceiling: at most one line per 30s of the window (~20 lines over 600s).
|
|
1324
|
+
const DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS = 30_000;
|
|
1325
|
+
export function createDeviceAuthProgressReporter(stream = process.stderr) {
|
|
1326
|
+
const isTty = stream.isTTY === true;
|
|
1327
|
+
let timer;
|
|
1328
|
+
let frame = 0;
|
|
1329
|
+
let finished = false;
|
|
1330
|
+
let remainingMs;
|
|
1331
|
+
let lastLineRemainingMs;
|
|
1332
|
+
const label = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
|
|
1333
|
+
const redraw = () => {
|
|
1334
|
+
if (remainingMs === undefined)
|
|
1335
|
+
return;
|
|
1336
|
+
stream.write(`\r\x1b[K${CLI_SPINNER_FRAMES[frame % CLI_SPINNER_FRAMES.length]} ${label(remainingMs)}`);
|
|
1337
|
+
};
|
|
1338
|
+
const stopTimer = () => {
|
|
1339
|
+
if (timer) {
|
|
1340
|
+
clearInterval(timer);
|
|
1341
|
+
timer = undefined;
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
return {
|
|
1345
|
+
tick(nextRemainingMs) {
|
|
1346
|
+
if (finished)
|
|
1347
|
+
return;
|
|
1348
|
+
const first = remainingMs === undefined;
|
|
1349
|
+
remainingMs = nextRemainingMs;
|
|
1350
|
+
if (isTty) {
|
|
1351
|
+
redraw();
|
|
1352
|
+
if (first) {
|
|
1353
|
+
// Sub-second liveness: the countdown itself only advances on a poll
|
|
1354
|
+
// boundary, which would look frozen for a whole interval at a time.
|
|
1355
|
+
timer = setInterval(() => {
|
|
1356
|
+
frame += 1;
|
|
1357
|
+
redraw();
|
|
1358
|
+
}, 120);
|
|
1359
|
+
// Never hold the process open on the reporter's account.
|
|
1360
|
+
if (typeof timer.unref === "function")
|
|
1361
|
+
timer.unref();
|
|
1362
|
+
}
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (lastLineRemainingMs !== undefined && lastLineRemainingMs - nextRemainingMs < DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS) {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
lastLineRemainingMs = nextRemainingMs;
|
|
1369
|
+
stream.write(`${label(nextRemainingMs)}\n`);
|
|
1370
|
+
},
|
|
1371
|
+
// Idempotent; called from a finally around the poll loop. On a TTY it erases
|
|
1372
|
+
// the in-place line and writes nothing in its place: unlike the upload
|
|
1373
|
+
// reporter this deliberately leaves no ✓/✗ summary, because the flow's own
|
|
1374
|
+
// success line or its thrown error is the outcome, and a second line here
|
|
1375
|
+
// would be exactly the leftover progress noise #509 forbids. Off-TTY the
|
|
1376
|
+
// lines are already terminated, and before the first tick there is nothing
|
|
1377
|
+
// to clean up.
|
|
1378
|
+
stop() {
|
|
1379
|
+
if (finished)
|
|
1380
|
+
return;
|
|
1381
|
+
finished = true;
|
|
1382
|
+
stopTimer();
|
|
1383
|
+
if (isTty && remainingMs !== undefined) {
|
|
1384
|
+
stream.write("\r\x1b[K");
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1189
1389
|
function buildContainerImage(directory) {
|
|
1190
1390
|
const root = path.resolve(directory);
|
|
1191
1391
|
if (!existsSync(path.join(root, "Dockerfile"))) {
|
|
@@ -1239,6 +1439,25 @@ async function expectOk(response, action) {
|
|
|
1239
1439
|
const text = await response.text();
|
|
1240
1440
|
throw new Error(`Failed to ${action}: ${response.status} ${text}`);
|
|
1241
1441
|
}
|
|
1442
|
+
// Pull the control plane's own message out of its {error:{code,message}} envelope,
|
|
1443
|
+
// for the few refusals whose message is written for the tester and is the whole
|
|
1444
|
+
// point of the response (e.g. device signup being unavailable, #507). Dumping the
|
|
1445
|
+
// raw envelope the way expectOk does would bury it in JSON. Falls back to
|
|
1446
|
+
// expectOk's text when the body isn't that shape, so this can never lose detail.
|
|
1447
|
+
async function apiErrorMessage(response, action) {
|
|
1448
|
+
const text = await response.text();
|
|
1449
|
+
try {
|
|
1450
|
+
const parsed = JSON.parse(text);
|
|
1451
|
+
const message = parsed.error?.message;
|
|
1452
|
+
if (typeof message === "string" && message.trim()) {
|
|
1453
|
+
return message.trim();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
// Not JSON (proxy error page, empty body) — fall through to the raw form.
|
|
1458
|
+
}
|
|
1459
|
+
return `Failed to ${action}: ${response.status} ${text}`;
|
|
1460
|
+
}
|
|
1242
1461
|
// #374: Node's fetch rejects network-level failures (no HTTP response at all)
|
|
1243
1462
|
// as a generic "fetch failed" TypeError and stashes the real reason on
|
|
1244
1463
|
// `error.cause` (e.g. { code: "EPIPE" }). That code never reaches the user.
|
|
@@ -1323,6 +1542,8 @@ const VALID_DEPLOY_FLAGS = new Set([
|
|
|
1323
1542
|
"db",
|
|
1324
1543
|
"with-auth",
|
|
1325
1544
|
"auth",
|
|
1545
|
+
"with-email",
|
|
1546
|
+
"email",
|
|
1326
1547
|
"name",
|
|
1327
1548
|
"slug",
|
|
1328
1549
|
"description",
|
|
@@ -1442,9 +1663,12 @@ export function parseArgs(argv) {
|
|
|
1442
1663
|
directory,
|
|
1443
1664
|
apiUrl: readApiUrl(flags, DEFAULT_API_URL),
|
|
1444
1665
|
token: token ?? "",
|
|
1445
|
-
// --with-auth implies a managed database (somewhere to store per-user data)
|
|
1446
|
-
|
|
1447
|
-
|
|
1666
|
+
// --with-auth implies a managed database (somewhere to store per-user data);
|
|
1667
|
+
// --with-email implies --with-auth (invitations need managed accounts), so it
|
|
1668
|
+
// pulls in the database too.
|
|
1669
|
+
withDatabase: flags.has("with-db") || flags.has("db") || flags.has("with-auth") || flags.has("auth") || flags.has("with-email") || flags.has("email"),
|
|
1670
|
+
withAuth: flags.has("with-auth") || flags.has("auth") || flags.has("with-email") || flags.has("email"),
|
|
1671
|
+
withEmail: flags.has("with-email") || flags.has("email"),
|
|
1448
1672
|
check,
|
|
1449
1673
|
};
|
|
1450
1674
|
const name = readStringFlag(flags, "name");
|
|
@@ -2481,10 +2705,45 @@ function deleteLinuxSecretServiceCredential() {
|
|
|
2481
2705
|
const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], { encoding: "utf8" });
|
|
2482
2706
|
return result.status === 0;
|
|
2483
2707
|
}
|
|
2708
|
+
// Pure and exported so the per-platform mapping is unit-testable without
|
|
2709
|
+
// spawning anything (#509). A spawn error or a non-zero exit is a real failure.
|
|
2710
|
+
// A zero exit means the handler ran — except on Windows, where the launcher
|
|
2711
|
+
// returns 0 whether or not anything actually rendered the page, so "opened"
|
|
2712
|
+
// there would be a claim we cannot back: report "unknown" and say nothing about
|
|
2713
|
+
// the browser rather than mislead. The unconditional paste-the-URL hint covers
|
|
2714
|
+
// that case anyway.
|
|
2715
|
+
export function classifyBrowserOpen(input) {
|
|
2716
|
+
if (input.error !== undefined) {
|
|
2717
|
+
return "failed";
|
|
2718
|
+
}
|
|
2719
|
+
if (input.status !== 0) {
|
|
2720
|
+
return "failed";
|
|
2721
|
+
}
|
|
2722
|
+
return input.platform === "win32" ? "unknown" : "opened";
|
|
2723
|
+
}
|
|
2724
|
+
// Headless boxes, containers and remote SSH sessions — the cases where the
|
|
2725
|
+
// browser handoff is least likely to work, and where `xdg-open` often exits 0
|
|
2726
|
+
// having done nothing, so the absence of any display is the stronger signal.
|
|
2727
|
+
// Pure and exported for the same reason as classifyBrowserOpen: the branch has
|
|
2728
|
+
// to be assertable without a real platform or a real spawn. Note
|
|
2729
|
+
// noUncheckedIndexedAccess makes these `string | undefined`; an empty value is
|
|
2730
|
+
// as useless as an absent one, so test truthiness, not presence.
|
|
2731
|
+
export function hasNoGraphicalDisplay(platform, env) {
|
|
2732
|
+
return platform === "linux" && !env["DISPLAY"] && !env["WAYLAND_DISPLAY"];
|
|
2733
|
+
}
|
|
2484
2734
|
function openBrowser(url) {
|
|
2735
|
+
if (hasNoGraphicalDisplay(process.platform, process.env)) {
|
|
2736
|
+
return "failed";
|
|
2737
|
+
}
|
|
2485
2738
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
2486
2739
|
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2487
|
-
|
|
2740
|
+
// stdio stays ignored: a browser's own chatter must not pollute the terminal.
|
|
2741
|
+
const result = spawnSync(command, args, { stdio: "ignore" });
|
|
2742
|
+
return classifyBrowserOpen({
|
|
2743
|
+
platform: process.platform,
|
|
2744
|
+
status: result.status,
|
|
2745
|
+
...(result.error ? { error: result.error } : {}),
|
|
2746
|
+
});
|
|
2488
2747
|
}
|
|
2489
2748
|
function sleep(ms) {
|
|
2490
2749
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -2519,6 +2778,16 @@ function formatBytesForCli(value) {
|
|
|
2519
2778
|
}
|
|
2520
2779
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
|
|
2521
2780
|
}
|
|
2781
|
+
// Human remaining time for the device-auth wait (#509). Ceil so a window with
|
|
2782
|
+
// any time left never reads "0s", and clamp so a passed deadline reads "0s"
|
|
2783
|
+
// rather than a negative.
|
|
2784
|
+
function formatRemainingForCli(value) {
|
|
2785
|
+
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
|
2786
|
+
if (totalSeconds < 60) {
|
|
2787
|
+
return `${totalSeconds}s`;
|
|
2788
|
+
}
|
|
2789
|
+
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
2790
|
+
}
|
|
2522
2791
|
export class UsageError extends Error {
|
|
2523
2792
|
}
|
|
2524
2793
|
async function main() {
|
|
@@ -2549,6 +2818,16 @@ async function main() {
|
|
|
2549
2818
|
}
|
|
2550
2819
|
return;
|
|
2551
2820
|
}
|
|
2821
|
+
// Fail fast with actionable guidance when the API host is unreachable — a
|
|
2822
|
+
// default ("Trusted") cloud sandbox blocks it at CONNECT, and the deploy
|
|
2823
|
+
// would otherwise hang/error cryptically on the first request (#83/#258).
|
|
2824
|
+
// Real invocations only; a programmatic caller injecting fetchFn opts out.
|
|
2825
|
+
if (!command.options.fetchFn &&
|
|
2826
|
+
(await probeApiReachability(normalizeApiUrl(command.options.apiUrl), fetch)) === "blocked") {
|
|
2827
|
+
console.error(apiUnreachableGuidance(command.options.apiUrl));
|
|
2828
|
+
process.exitCode = 1;
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2552
2831
|
let result;
|
|
2553
2832
|
try {
|
|
2554
2833
|
result = await deployDirectory(command.options);
|
|
@@ -2829,7 +3108,7 @@ async function runFilesCommand(command) {
|
|
|
2829
3108
|
function printUsage() {
|
|
2830
3109
|
console.log(`Usage:
|
|
2831
3110
|
9pm --version
|
|
2832
|
-
9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth]
|
|
3111
|
+
9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
|
|
2833
3112
|
9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
|
|
2834
3113
|
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
|
|
2835
3114
|
9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
|
|
@@ -2877,6 +3156,7 @@ Container deploys:
|
|
|
2877
3156
|
The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
|
|
2878
3157
|
With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
|
|
2879
3158
|
With --with-auth, the app gets 9pm-managed end-user accounts (email one-time codes); it implies --with-db and is one-way (enable-only).
|
|
3159
|
+
With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
|
|
2880
3160
|
9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
|
|
2881
3161
|
9pm env set <app> --from-file <path> [--var] (KEY=value lines; # comments, export prefix, quotes ok)
|
|
2882
3162
|
9pm env set <app> <NAME> --stdin [--var] (value read verbatim from stdin)
|