Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .changeset/cloud-login-json-ndjson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
'@objectstack/cli': minor
---

**BREAKING (`os cloud login --json` stdout wire shape):** it is now an NDJSON
stream, one compact JSON document per line, and it emits a verification-URL
record it never used to emit at all (#6730).

`os cloud login --json` passed `silent: true` into the device flow and nothing
else. Formally that was impeccable — stdout carried exactly one JSON document
and `JSON.parse(<entire stdout>)` read it. Measured against a live RFC 8628
endpoint, the whole of stdout for an interactive `--json --no-browser` run was:

```
{
"success": true,
"email": "user@example.com",
"userId": "usr_…",
"url": "https://cloud.objectos.ai"
}
```

The verification URL appeared nowhere — not on stdout, not on stderr. `silent`
suppressed the human-readable print and put nothing in its place, so the one
thing device flow exists to give a script (the URL, while there is still time to
act on it) was withheld from the only caller that cannot ask a human for it. A
consumer received a well-formed document describing an authorization it had no
way to trigger.

`os cloud login --json` is now a **newline-delimited JSON stream**: one compact
document per line, on every path — the device-authorization record, the
`--email`/`--password` result, the already-logged-in notice, and the
`{"success":false,"error":"…"}` failure record alike. The device record is
field-identical to the one `os login --json` emits (#6531), so one consumer
reads both commands.

This is the second and last of the CLI's **declared exceptions** to "`--json`
means exactly one JSON document on stdout" (#6217) — `os login` is the other,
and they are now the same exception rather than two answers to one question.
Both are declared rather than silent: the `--json` flag's `--help` text says so,
and so do the CLI reference page (`os cloud login --json` is NDJSON) and the
cloud publish flow on the deployment page. **Parse this command's stdout line by
line.**

### What breaks, and what to change

Unlike `os login`, whose device-flow output was unparseable in any shape and so
had no consumers to break, `os cloud login --json` worked today. If you consume
it:

- **Interactive/device-flow runs now emit two lines instead of one.**
`JSON.parse(<entire stdout>)` throws on the second document. Read the stream a
line at a time and act on the record you care about — the device record is the
one carrying `verification_uri`, the result the one carrying `success`.
- **Unattended runs are the safest migration and were already correct.**
`os cloud login --email … --password …` never enters the device flow and still
emits exactly one record; the only change there is that it is compact rather
than 2-space indented, which `JSON.parse` reads identically.
- **Exit codes are unchanged**: `1` on a login failure, `0` otherwise.

### Why `minor` and not `patch`

Deliberately not the `patch` #6531/PR #6727 took. That bump rested on "nothing
that previously worked stops working", which was true there — the output was
unreadable before. It is false here: a single-document reader of
`os cloud login --json` works today and stops working on the device-flow path.
The bump follows the wire shape, not the size of the diff.

`major` is not the alternative: every publishable package versions in lockstep,
so during the launch window a breaking change ships as `minor` by convention and
`scripts/check-changeset-no-major.mjs` enforces it. `minor` is therefore the
highest bump this change can carry, and the disclosure above — not the number —
is what has to do the work of warning a consumer.

<!-- adr-0087: not-required (no-migration-prescription) what changes is one CLI command's stdout STREAM shape. No authorable key, no exported symbol and no stored value moves: `packages/spec` is untouched, no metadata schema gains or loses a key, and nothing an app authored or persisted becomes invalid or unparseable — so `objectstack migrate meta` has nothing to convert and neither `spec-changes.json` nor the generated upgrade guide has anything to carry. The consumer action prescribed above is rewriting a SCRIPT that reads this command's stdout (parse line by line instead of one `JSON.parse`), which is a channel the ADR-0087 ledger does not serve at all; the channels that do reach those readers are this changeset's own CHANGELOG text, the `--json` `--help` line, and the CLI reference page — all three shipped with this change. -->

60 changes: 60 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,7 @@ os doctor -v # Show fix suggestions for warnings
| `os login` | Sign in and store credentials in `~/.objectstack/credentials.json` |
| `os whoami` | Show the current authenticated user |
| `os logout` | Revoke the server session and clear local credentials |
| `os cloud login` | Sign in to ObjectStack Cloud (the hosted package registry) and store credentials in `~/.objectstack/cloud.json` |

#### `os register`

Expand Down Expand Up @@ -1121,6 +1122,65 @@ the server-side session is revoked as well.
os logout
```

#### `os cloud login`

Signs you in to **ObjectStack Cloud** — the hosted package registry — rather
than to a runtime instance. It is the credential `os package publish` and the
marketplace commands use, and it lands in its own file
(`~/.objectstack/cloud.json`), separate from `os login`'s
`~/.objectstack/credentials.json`.

```bash
os cloud login
os cloud login --no-browser
os cloud login --url https://cloud.example.com # self-hosted control plane
os cloud login --email me@acme.com --password secret # CI
```

Like `os login`, in an interactive terminal it uses the browser-based device
flow: it prints a one-time verification URL and polls until you approve. If
cloud credentials already exist it exits successfully with "Already logged in";
pass `--force` to re-authenticate.

##### `os cloud login --json` is NDJSON — the same exception as `os login`

Every other ObjectStack command writes **exactly one JSON document** to stdout
under `--json`, so `JSON.parse(<entire stdout>)` is the way to read it. The two
device-flow login commands — `os login` and `os cloud login` — are the declared
exceptions, and they are the **same** exception: `--json` output is **NDJSON**,
one compact JSON document per line. **Parse it line by line.**

The reason is the device flow: it is two events at two points in time, and the
verification URL is only useful to a script *before* the user authorizes. So the
CLI emits it as its own record immediately, then a second record when the poll
resolves:

```console
$ os cloud login --json --no-browser
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
{"success":true,"email":"user@example.com","userId":"usr_01H…","url":"https://cloud.objectos.ai"}
```

Read the first record, show the user the URL, then block on the next line:

```bash
os cloud login --json --no-browser | while IFS= read -r line; do
echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
done
```

Every record is one line, on every path — the `--email`/`--password` result, the
"already logged in" notice, and the failure payload
(`{"success":false,"error":"…"}`) included, since a failure can arrive *after*
the verification-URL record has already been written. Records that report a
login failure also set exit code `1`.

Before this was declared, `os cloud login --json` emitted a single document and
**never handed the verification URL to a consumer at all** — formally valid
JSON that withheld the one thing device flow exists to give a script. The
device-authorization record's fields are spelled exactly as `os login --json`
spells them, so one consumer reads both commands.

### Cloud Environments

| Command | Description |
Expand Down
10 changes: 10 additions & 0 deletions content/docs/deployment/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ os package publish # → sys_package + immutable, checksummed sys_package_ve
See the cloud [package](/docs/references/cloud/package) and
[package-version](/docs/references/cloud/package-version) references.

> **Automating the login step.** `os cloud login` uses the browser device flow
> in an interactive terminal, and under `--json` it is one of the CLI's two
> declared **NDJSON** commands (`os login` is the other): stdout is a stream of
> compact JSON documents, **one per line**, and the verification-URL record is
> written *before* you authorize so a script can show it while it still
> matters. Parse that stdout line by line, not with a single `JSON.parse`. See
> [`os cloud login`](/docs/deployment/cli#os-cloud-login) in the CLI reference.
> For an unattended pipeline, `os cloud login --email … --password …` skips the
> device flow entirely and emits a single record.

Installing then happens **inside the target environment**: sign in to that
environment's Console → **Marketplace** → pick the package → **Install**. The
install is authorized by your environment login and applied to that environment
Expand Down
101 changes: 96 additions & 5 deletions packages/cli/src/commands/cloud/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,88 @@
* *runtime* ObjectOS instance. Cloud credentials are persisted to
* `~/.objectstack/cloud.json` and consumed by `os package publish`,
* `os package install`, and any future marketplace commands.
*
* ## `--json` here is NDJSON — a declared exception, same as `os login` (#6730)
*
* Everywhere else in this CLI `--json` means "stdout is exactly one JSON
* document" (#6217). Both device-flow login commands are declared exceptions to
* that, and they are the SAME exception: one compact JSON document per line.
*
* ### What was broken
*
* This command used to pass `silent: flags.json` into the shared device flow
* and nothing else. Formally that was impeccable — stdout carried a single
* document and `JSON.parse` read it fine. Measured on `origin/main` against a
* live RFC 8628 endpoint, `os cloud login --json --no-browser` emitted exactly
* this and nothing more:
*
* ```
* {
* "success": true,
* "email": "device@example.com",
* "userId": "usr_6730",
* "url": "http://127.0.0.1:<port>"
* }
* ```
*
* The verification URL never appeared — not on stdout, not on stderr. `silent`
* suppressed the human-readable print and put nothing in its place, so the one
* thing device flow exists to give a script was withheld from the only caller
* that cannot ask a human for it. A consumer got a parseable document that
* arrives *after* an authorization it had no way to trigger.
*
* ### Why a stream rather than one document
*
* Maintainer ruling, 2026-08-08 (#6730, extending #6531): device flow is two
* events at two points in time, and emitting the verification URL **before**
* the user authorizes is its entire value in automation. Buffering both halves
* into one trailing document would keep stdout single-document by destroying
* the thing the output exists for; putting the early record on stderr would
* abuse the diagnostic stream for non-diagnostic content. And the ruling
* refused to let the two sibling commands answer this differently: a script
* author — human or AI — who learns the contract from the `os login` docs and
* applies it here must be right.
*
* The ruling's binding condition is that the exception be *declared*: this
* command's `--json` `--help` text says so, and so do
* `content/docs/deployment/cli.mdx` and the cloud-deployment flow in
* `content/docs/deployment/index.mdx`. An undocumented exception does the same
* harm to a consumer as the bug it replaces.
*
* ### Why EVERY write, not just the device flow's
*
* The contract belongs to the command, not to one of its paths. The failure
* record is reachable *after* the device record has already been written (a
* denied approval, an expired code, a poll failure), so an indented payload
* there would rebuild a two-document stream on the run a consumer can least
* afford to misread. All four `--json` writes go through {@link emitRecord},
* the only emitter in this file, which makes "one compact document per line" a
* structural property of the command instead of four call sites that each have
* to remember an option.
* `packages/cli/test/cloud-login-json-ndjson.e2e.test.ts` pins both halves.
*/

import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { Command, Flags } from '@oclif/core';
import type { CliExitCode } from '../../utils/format.js';
import { printHeader, printKV, printSuccess, printError, emitJson } from '../../utils/format.js';
import { loginWithBrowser, loginWithPassword } from '../../utils/auth-flows.js';
import { DEFAULT_CLOUD_URL, readCloudConfig, writeCloudConfig } from '../../utils/cloud-config.js';

/**
* Emit ONE NDJSON record on stdout — the only `--json` writer in this command.
*
* Compact is not a formatting preference here, it is the contract: a record
* that wrapped onto a second line would silently break every consumer reading
* this command's stdout a line at a time. Routing all four call sites through
* one helper is what makes that structural — see the file header for why the
* whole command, and not only the device flow, has to hold it.
*/
async function emitRecord(payload: unknown, exitCode: CliExitCode = 0): Promise<void> {
await emitJson(payload, exitCode, { compact: true });
}

async function promptPassword(promptText: string): Promise<string> {
if (!process.stdin.isTTY) {
const rl = readline.createInterface({ input, output });
Expand Down Expand Up @@ -93,7 +166,10 @@ export default class CloudLogin extends Command {
description: 'Re-authenticate even if cloud credentials already exist',
default: false,
}),
json: Flags.boolean({ description: 'Output as JSON' }),
json: Flags.boolean({
description:
'Machine-readable output as NDJSON — one compact JSON document per line. Unlike every other ObjectStack command, whose --json stdout is a single document, this one is a stream: the device flow reports the verification URL as its own record BEFORE you authorize, then the result as a second record. Parse stdout line by line. `os login --json` is the same exception with the same shape.',
}),
};

async run(): Promise<void> {
Expand All @@ -105,7 +181,7 @@ export default class CloudLogin extends Command {
const existing = await readCloudConfig();
if (existing?.token) {
if (flags.json) {
await emitJson({ success: false, error: 'Already logged in', email: existing.email, url: existing.url }, 0, { compact: true });
await emitRecord({ success: false, error: 'Already logged in', email: existing.email, url: existing.url });
} else {
printSuccess(`Already logged in to ${existing.url} as ${existing.email || existing.userId}`);
console.log('');
Expand All @@ -131,7 +207,16 @@ export default class CloudLogin extends Command {
flags.email && flags.password
? await loginWithPassword(url, flags.email, flags.password)
: process.stdin.isTTY && !flags.email && !flags.password
? await loginWithBrowser(url, { noBrowser: flags['no-browser'], silent: flags.json })
? await loginWithBrowser(url, {
noBrowser: flags['no-browser'],
silent: flags.json,
// Record 1 of 2, and deliberately written BEFORE the poll loop:
// an automation consumer needs the verification URL while it can
// still act on it, which is the reason this command is a stream
// at all. `silent` alone is what made it vanish (#6730).
// Undefined in human mode so the flow keeps its own printer.
onDeviceCode: flags.json ? ({ record }) => emitRecord(record) : undefined,
})
: await this.fallbackPasswordPrompt(url, flags.email);

await writeCloudConfig({
Expand All @@ -143,7 +228,9 @@ export default class CloudLogin extends Command {
});

if (flags.json) {
await emitJson({ success: true, email: result.user?.email, userId: result.user?.id, url });
// Record 2 of 2 on the device path, and the only record on the
// --email/--password path — same line-per-document shape either way.
await emitRecord({ success: true, email: result.user?.email, userId: result.user?.id, url });
} else {
printSuccess('Cloud authentication successful');
if (result.user?.email) printKV('Email', result.user.email);
Expand All @@ -155,7 +242,11 @@ export default class CloudLogin extends Command {
}
} catch (error: any) {
if (flags.json) {
await emitJson({ success: false, error: error.message });
// Reachable AFTER the device-authorization record has already been
// written (an expired code, a denied approval, a poll failure), so an
// indented payload here would recreate a two-document stream on the
// path a consumer is least able to recover from.
await emitRecord({ success: false, error: error.message });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
Loading
Loading