@ossy/cli 1.16.10 → 1.16.11

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 CHANGED
@@ -18,15 +18,86 @@ ossy <command> --help
18
18
 
19
19
  For one-off use without installing, you can still run `npx @ossy/cli <command>`.
20
20
 
21
- ## Commands
21
+ ## How the CLI is organized
22
22
 
23
- | Command | Description |
24
- |---------|-------------|
25
- | `init [dir]` | Scaffold a new Ossy app (default: current directory) |
26
- | `build` | Production build |
27
- | `publish` | Queue a container deployment via `@ossy/deployment-tools` (**temporary**; see below), then upload `resourceTemplates` and **site build artifacts** (S3 presign + CMS resource) when `workspaceId` is set |
28
- | `cms upload` | Upload resource templates only (same API as publish’s upload step) |
29
- | `cms validate` | Validate ossy config and resource templates |
23
+ The contract for everything the platform can do is the **SDK action POJO** in [`@ossy/sdk`](../sdk) (`{ id, endpoint, method, payload }`). The CLI is one channel onto that catalog, organized in three layers:
24
+
25
+ 1. **Local utilities** no API call, or per-machine state. `init`, `build`, `auth`, `workspace`, `cms validate`.
26
+ 2. **Action dispatcher** — `call <action.id>` invokes any action in the SDK catalog. New endpoints added to the SDK are callable from the terminal with no extra CLI wiring.
27
+ 3. **High-level workflows** verbs that wrap one or more actions and add value the dispatcher doesn't (reading `src/config.js`, formatting output for CI, multi-step pipelines). Each workflow's docs name the underlying action(s) so you can drop down to `ossy call` if you outgrow the verb.
28
+
29
+ | Layer | Command | Description |
30
+ |-------|---------|-------------|
31
+ | Local | `auth login \| logout \| status` | Save / delete / inspect the Ossy API token under `~/.config/ossy/credentials.json` |
32
+ | Local | `workspace use <id> \| list \| current` | Manage the active workspace (saved to `~/.config/ossy/config.json`) |
33
+ | Local | `init [dir]` | Scaffold a new Ossy app (default: current directory) |
34
+ | Local | `build` | Production build of the app in the current directory |
35
+ | Local | `cms validate` | Validate `src/config.js` (`workspaceId`, `resourceTemplates`) without calling the API |
36
+ | Dispatcher | `call <action.id>` | Invoke any Ossy SDK action by id (`ossy call --help` for the full catalog) |
37
+ | Workflow | `cms upload` | Read `resourceTemplates` from `src/config.js` and call `workspaces.import-resource-templates` |
38
+ | Workflow | `registry ecr-push-credentials` | Call `registry.ecr-push-credentials`, format for `docker login` or GitHub Actions |
39
+ | Workflow | `publish` | Queue a deployment via `@ossy/deployment-tools` (**temporary**; see below), then run the resource-template and site-artifact uploads |
40
+
41
+ If you need an endpoint that no verb wraps, reach for `ossy call` — it's the universal channel.
42
+
43
+ ## Auth and active workspace
44
+
45
+ Once installed, the recommended flow is to log in once and pick an active workspace; subsequent commands inherit those:
46
+
47
+ ```bash
48
+ ossy auth login # paste your Ossy API token
49
+ ossy workspace list # see workspaces you can access
50
+ ossy workspace use <workspace-id> # set the active one
51
+ ossy auth status # confirm
52
+ ```
53
+
54
+ Credentials live in `~/.config/ossy/credentials.json` (`chmod 600`); the active workspace lives in `~/.config/ossy/config.json`. Both honor `XDG_CONFIG_HOME`.
55
+
56
+ Resolution order for every command that needs auth:
57
+ 1. `--authentication` / `-a` flag
58
+ 2. `OSSY_API_KEY` env var
59
+ 3. Saved credentials
60
+
61
+ Resolution order for the workspace id (when a command isn't reading it from `src/config.js`):
62
+ 1. `--workspace-id` / `-w` flag
63
+ 2. `OSSY_WORKSPACE_ID` env var
64
+ 3. Saved active workspace
65
+
66
+ So existing CI scripts that set `OSSY_API_KEY` keep working unchanged; you only need `ossy auth login` for interactive use.
67
+
68
+ ## Generic dispatcher: `ossy call`
69
+
70
+ `ossy call` invokes any Ossy API action by id. The action catalog comes from `@ossy/sdk`, so any new endpoint added to the SDK is callable from the terminal with no extra wiring.
71
+
72
+ ```bash
73
+ # Discover what's available
74
+ ossy call --help # full catalog, grouped by domain
75
+ ossy call resources.create --help # one action's method, endpoint, default payload
76
+
77
+ # Read calls
78
+ ossy call workspaces.get-current
79
+ ossy call resources.list --search 'location=/docs'
80
+
81
+ # Write calls — flag names map to payload keys (--first-name -> firstName)
82
+ ossy call resources.create-directory --location /docs --name images
83
+ ossy call workspaces.invite-user --email teammate@example.com
84
+
85
+ # Use --json for nested or non-string payloads
86
+ ossy call resources.update-content --id res_abc --json '{"content":{"title":"Hi"}}'
87
+
88
+ # Upload a file: --file fills name/type/size as defaults, then PUTs the bytes
89
+ # when the response carries content.uploadUrl (works for resources.upload,
90
+ # resources.upload-named-version, etc.)
91
+ ossy call resources.upload --location /docs --file ./hero.jpg
92
+ ossy call resources.upload-named-version --id res_abc --name medium --file ./hero.jpg
93
+
94
+ # Per-call overrides (otherwise resolved from `ossy auth login` / `ossy workspace use`)
95
+ ossy call workspaces.get-all -a <jwt> --api-url http://localhost:3001/api/v0
96
+ ```
97
+
98
+ Output is JSON pretty-printed to stdout on success. Errors go to stderr with a non-zero exit. Add `--raw` to get the response body verbatim (useful for piping non-JSON responses).
99
+
100
+ `--file <path>` is a shortcut for upload-style actions: the CLI stats the file, fills `name`/`type`/`size` *as defaults* (any explicit `--name` / `--type` / `--size` flag wins), and after the dispatch it PUTs the bytes to `result.content.uploadUrl` with the right `Content-Type`/`Content-Length`. If the response doesn't include an upload URL, the bytes are skipped and a warning is printed.
30
101
 
31
102
  ## App: build
32
103
 
@@ -40,6 +111,8 @@ Options: e.g. `--config` for `src/config.js`. See `packages/app/README.md` for a
40
111
 
41
112
  Publishes a site by sending a deployment request to your platform queue (same as `npx @ossy/deployment-tools deployment deploy`). Run from the **website package** directory (where `src/config.js` lives) so domain/platform can be read automatically.
42
113
 
114
+ **This is a multi-step workflow, not a single SDK action.** It shells out to `@ossy/deployment-tools` for the deploy and then performs two CMS uploads (resource templates → `workspaces.import-resource-templates`; site build artifacts → `/site-artifacts/presign-batch` + `/site-artifacts/commit-batch`). The deploy step is **temporary** — see *Future direction* below.
115
+
43
116
  ### Temporary: no execution of `src/config.js` for CMS steps
44
117
 
45
118
  After deploy succeeds, **`publish`** still needs **`workspaceId`**, **`apiUrl`** (optional, for absolute URLs), and **`resourceTemplates`** from `src/config.js`. Those values are **not** loaded with `import()` anymore: running the real config in plain Node would execute imports such as `@ossy/themes`, which are only meant to be resolved during **`ossy build`** (Rollup).
@@ -90,6 +163,12 @@ Requires network access so `npx` can run `@ossy/deployment-tools`.
90
163
 
91
164
  Upload resource templates to your workspace so they can be used in the UI.
92
165
 
166
+ Wraps the SDK action **`workspaces.import-resource-templates`** and adds: reading `resourceTemplates` and `workspaceId` from `src/config.js` (so you don't have to inline the templates JSON yourself). If you already have the templates as a JSON object, the equivalent dispatcher call is:
167
+
168
+ ```bash
169
+ ossy call workspaces.import-resource-templates --json '{"templates":[…]}'
170
+ ```
171
+
93
172
  Prefer **`--authentication` / `-a`** for the **Ossy API JWT** (same as **`publish`**); it matches **`OSSY_API_KEY`** in CI.
94
173
 
95
174
  ```bash
@@ -148,6 +227,25 @@ When **`--config`** is omitted, **`./src/config.js`** is used if it exists.
148
227
  | --config, -c | App config (`workspaceId`, `resourceTemplates`, …) | Optional if `./src/config.js` exists |
149
228
  | --api-url | API base URL for upload (`…/api/v0`) | No |
150
229
 
230
+ ## Registry: ecr-push-credentials
231
+
232
+ Fetch a short-lived ECR password so CI can `docker login` and `docker push` to the workspace's ECR registry.
233
+
234
+ Wraps the SDK action **`registry.ecr-push-credentials`** and adds: response validation, JSON pretty-printing, and a `--format github-actions` mode that writes `registry`/`username`/`password`/`expires_at` to `$GITHUB_OUTPUT` and emits `::add-mask::<password>` so the secret never appears in logs.
235
+
236
+ ```bash
237
+ ossy registry ecr-push-credentials # JSON to stdout
238
+ ossy registry ecr-push-credentials --format github-actions # writes to $GITHUB_OUTPUT
239
+ ```
240
+
241
+ Auth and workspace are resolved from the usual chain (`-a` flag → `OSSY_API_KEY` → saved credentials; `-w` flag → `OSSY_WORKSPACE_ID` → saved active workspace). The plain dispatcher equivalent is:
242
+
243
+ ```bash
244
+ ossy call registry.ecr-push-credentials
245
+ ```
246
+
247
+ …but you lose the GitHub Actions output formatting and the `::add-mask::` line, so prefer the verb in CI.
248
+
151
249
  ## init
152
250
 
153
251
  Scaffold a new Ossy app:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/cli",
3
- "version": "1.16.10",
3
+ "version": "1.16.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ossy-se/packages.git"
@@ -20,7 +20,8 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@babel/parser": "^7.28.6",
23
- "@ossy/app": "^1.16.10",
23
+ "@ossy/app": "^1.16.11",
24
+ "@ossy/sdk": "^1.16.11",
24
25
  "arg": "^5.0.2",
25
26
  "glob": "^10.3.10"
26
27
  },
@@ -32,5 +33,5 @@
32
33
  "/src",
33
34
  "README.md"
34
35
  ],
35
- "gitHead": "5821b2cea64c9ca0ece35d65a463006852abf167"
36
+ "gitHead": "17a1a491a5239502f989b3745dab7a6c8ae17076"
36
37
  }
@@ -0,0 +1,135 @@
1
+ import readline from 'node:readline/promises'
2
+ import { stdin as input, stdout as output } from 'node:process'
3
+ import arg from 'arg'
4
+ import { logInfo, logError } from '../log.js'
5
+ import {
6
+ readCredentials,
7
+ writeCredentials,
8
+ deleteCredentials,
9
+ getAuth,
10
+ getApiUrl,
11
+ getWorkspaceId,
12
+ describeStatePaths,
13
+ } from '../state.js'
14
+
15
+ function stripBearer (t) {
16
+ return String(t ?? '').trim().replace(/^Bearer\s+/i, '').trim()
17
+ }
18
+
19
+ async function promptHidden (question) {
20
+ // Tokens contain dots; avoid leaking partial input in shell history. Echo is left on
21
+ // because Node's tty masking is fiddly across platforms — paste-and-press-enter is
22
+ // the documented flow. We could revisit with a real prompt lib later.
23
+ const rl = readline.createInterface({ input, output })
24
+ try {
25
+ return (await rl.question(question)).trim()
26
+ } finally {
27
+ rl.close()
28
+ }
29
+ }
30
+
31
+ async function login (options) {
32
+ const parsed = arg({
33
+ '--token': String,
34
+ '-t': '--token',
35
+ '--api-url': String,
36
+ }, { argv: options })
37
+
38
+ let token = parsed['--token']
39
+ if (!token) {
40
+ if (!input.isTTY) {
41
+ logError({
42
+ message:
43
+ '[@ossy/cli] auth login: pass --token <jwt> or run interactively in a TTY.',
44
+ })
45
+ process.exit(1)
46
+ }
47
+ token = await promptHidden('Paste your Ossy API token: ')
48
+ }
49
+
50
+ const normalized = stripBearer(token)
51
+ if (!normalized) {
52
+ logError({ message: '[@ossy/cli] auth login: empty token.' })
53
+ process.exit(1)
54
+ }
55
+
56
+ const apiUrl = getApiUrl({ flag: parsed['--api-url'] })
57
+
58
+ let me = null
59
+ try {
60
+ const res = await fetch(`${apiUrl}/users/me`, {
61
+ headers: { Authorization: normalized, 'Content-Type': 'application/json' },
62
+ })
63
+ if (!res.ok) {
64
+ logError({
65
+ message: `[@ossy/cli] auth login: token rejected by ${apiUrl}/users/me (HTTP ${res.status})`,
66
+ })
67
+ process.exit(1)
68
+ }
69
+ me = await res.json().catch(() => null)
70
+ } catch (error) {
71
+ logError({ message: `[@ossy/cli] auth login: could not reach ${apiUrl}`, error })
72
+ process.exit(1)
73
+ }
74
+
75
+ writeCredentials({ apiUrl, token: normalized })
76
+ logInfo({
77
+ message: `[@ossy/cli] Logged in as ${me?.email || me?.id || 'user'} on ${apiUrl}`,
78
+ })
79
+ }
80
+
81
+ function logout () {
82
+ deleteCredentials()
83
+ logInfo({ message: '[@ossy/cli] Logged out' })
84
+ }
85
+
86
+ async function status () {
87
+ const creds = readCredentials()
88
+ const paths = describeStatePaths()
89
+ const apiUrl = getApiUrl()
90
+ const workspaceId = getWorkspaceId()
91
+
92
+ if (!creds?.token) {
93
+ console.log(
94
+ `Not logged in. Run \`ossy auth login\` to save credentials at ${paths.credentials}.`
95
+ )
96
+ return
97
+ }
98
+
99
+ const auth = getAuth()
100
+ let userInfo = null
101
+ let live = false
102
+ try {
103
+ const res = await fetch(`${apiUrl}/users/me`, {
104
+ headers: { Authorization: auth, 'Content-Type': 'application/json' },
105
+ })
106
+ if (res.ok) {
107
+ userInfo = await res.json().catch(() => null)
108
+ live = true
109
+ }
110
+ } catch {
111
+ // network failure → just report offline status
112
+ }
113
+
114
+ console.log(`API URL: ${apiUrl}`)
115
+ console.log(
116
+ `User: ${live ? (userInfo?.email || userInfo?.id || 'unknown') : '(token rejected — try `ossy auth login`)'}`
117
+ )
118
+ console.log(
119
+ `Workspace: ${workspaceId || '(none — run `ossy workspace use <id>`)'}`
120
+ )
121
+ console.log(`Creds file: ${paths.credentials}`)
122
+ console.log(`Config: ${paths.config}`)
123
+ }
124
+
125
+ export async function handler (args) {
126
+ const [sub, ...rest] = args
127
+ if (sub === 'login') return await login(rest)
128
+ if (sub === 'logout') return logout()
129
+ if (sub === 'status') return await status()
130
+ logError({
131
+ message:
132
+ '[@ossy/cli] auth: unknown subcommand. Use: ossy auth login | logout | status',
133
+ })
134
+ process.exit(1)
135
+ }
@@ -0,0 +1,327 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import * as Sdk from '@ossy/sdk'
3
+ import { logError } from '../log.js'
4
+ import { getAuth, getApiUrl, getWorkspaceId } from '../state.js'
5
+ import { prepareFileUpload } from '../file.js'
6
+
7
+ const ACTIONS = Object.values(Sdk).filter(
8
+ (v) =>
9
+ v
10
+ && typeof v === 'object'
11
+ && typeof v.id === 'string'
12
+ && typeof v.endpoint === 'string'
13
+ && typeof v.method === 'string'
14
+ )
15
+
16
+ const ACTION_BY_ID = new Map(ACTIONS.map((a) => [a.id, a]))
17
+
18
+ function listActionsByDomain () {
19
+ const grouped = new Map()
20
+ for (const action of ACTIONS) {
21
+ const domain = action.id.split('.')[0]
22
+ if (!grouped.has(domain)) grouped.set(domain, [])
23
+ grouped.get(domain).push(action.id)
24
+ }
25
+ for (const [, ids] of grouped) ids.sort()
26
+ return new Map([...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)))
27
+ }
28
+
29
+ function buildHelp () {
30
+ const grouped = listActionsByDomain()
31
+ const blocks = []
32
+ for (const [domain, ids] of grouped) {
33
+ blocks.push(` ${domain}\n${ids.map((id) => ` ${id}`).join('\n')}`)
34
+ }
35
+ return `ossy call <action.id> [--key value]... [--json '{...}'] [options]
36
+
37
+ Generic dispatcher for any Ossy API action. Flags become payload fields
38
+ (--first-name -> firstName); --json '{...}' is merged on top.
39
+
40
+ Options:
41
+ --json '{...}' Provide payload as JSON (merged with --key value flags)
42
+ --file <path> Attach a local file: derives name/type/size as defaults
43
+ and PUTs the bytes if the response returns content.uploadUrl
44
+ -w, --workspace-id Override active workspace id (sent as workspaceId header)
45
+ -a, --authentication Override API token (otherwise resolved from state/env)
46
+ --api-url Override API base URL
47
+ --raw Print response body without JSON pretty-printing
48
+
49
+ Per-action help:
50
+ ossy call <action.id> --help
51
+
52
+ Available actions:
53
+
54
+ ${blocks.join('\n\n')}
55
+ `
56
+ }
57
+
58
+ function actionHelp (action) {
59
+ const def = action.payload ? `Default payload:\n ${JSON.stringify(action.payload)}\n\n` : ''
60
+ return `${action.id} ${action.method} ${action.endpoint}
61
+
62
+ ${def}Examples:
63
+ ossy call ${action.id} --key value --other value
64
+ ossy call ${action.id} --json '{"key":"value"}'
65
+ `
66
+ }
67
+
68
+ function kebabToCamel (str) {
69
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
70
+ }
71
+
72
+ const RESERVED_FLAGS_NEEDING_VALUE = new Set([
73
+ '--json',
74
+ '--workspace-id', '-w',
75
+ '--authentication', '-a',
76
+ '--api-url',
77
+ '--file',
78
+ ])
79
+
80
+ function parseCallArgs (argv) {
81
+ const out = {
82
+ actionId: null,
83
+ payload: {},
84
+ workspaceId: null,
85
+ authentication: null,
86
+ apiUrl: null,
87
+ raw: false,
88
+ json: null,
89
+ file: null,
90
+ explicitPayloadKeys: new Set(),
91
+ help: false,
92
+ }
93
+
94
+ let i = 0
95
+ while (i < argv.length) {
96
+ const arg = argv[i]
97
+
98
+ if (arg === '--help' || arg === '-h') {
99
+ out.help = true
100
+ i++
101
+ continue
102
+ }
103
+ if (arg === '--raw') {
104
+ out.raw = true
105
+ i++
106
+ continue
107
+ }
108
+
109
+ // `--key=value` form, including reserved flags
110
+ if (arg.startsWith('--') && arg.includes('=')) {
111
+ const eq = arg.indexOf('=')
112
+ const key = arg.slice(2, eq)
113
+ const value = arg.slice(eq + 1)
114
+ if (key === 'json') out.json = value
115
+ else if (key === 'workspace-id') out.workspaceId = value
116
+ else if (key === 'authentication') out.authentication = value
117
+ else if (key === 'api-url') out.apiUrl = value
118
+ else if (key === 'file') out.file = value
119
+ else {
120
+ const camel = kebabToCamel(key)
121
+ out.payload[camel] = value
122
+ out.explicitPayloadKeys.add(camel)
123
+ }
124
+ i++
125
+ continue
126
+ }
127
+
128
+ // Reserved space-separated flags
129
+ if (RESERVED_FLAGS_NEEDING_VALUE.has(arg)) {
130
+ const value = argv[i + 1]
131
+ if (arg === '--json') out.json = value
132
+ else if (arg === '--workspace-id' || arg === '-w') out.workspaceId = value
133
+ else if (arg === '--authentication' || arg === '-a') out.authentication = value
134
+ else if (arg === '--api-url') out.apiUrl = value
135
+ else if (arg === '--file') out.file = value
136
+ i += 2
137
+ continue
138
+ }
139
+
140
+ // Action.id is the first non-flag positional
141
+ if (!arg.startsWith('-') && out.actionId === null) {
142
+ out.actionId = arg
143
+ i++
144
+ continue
145
+ }
146
+
147
+ // Generic --key (value | true)
148
+ if (arg.startsWith('--')) {
149
+ const key = arg.slice(2)
150
+ const camel = kebabToCamel(key)
151
+ const next = argv[i + 1]
152
+ if (next === undefined || next.startsWith('-')) {
153
+ out.payload[camel] = true
154
+ out.explicitPayloadKeys.add(camel)
155
+ i++
156
+ } else {
157
+ out.payload[camel] = next
158
+ out.explicitPayloadKeys.add(camel)
159
+ i += 2
160
+ }
161
+ continue
162
+ }
163
+
164
+ // Stray positional after the action id — ignore
165
+ i++
166
+ }
167
+
168
+ if (out.json) {
169
+ let parsed
170
+ try {
171
+ parsed = JSON.parse(out.json)
172
+ } catch (e) {
173
+ throw new Error(`--json: invalid JSON (${e.message})`)
174
+ }
175
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
176
+ throw new Error('--json: must be a JSON object')
177
+ }
178
+ for (const k of Object.keys(parsed)) out.explicitPayloadKeys.add(k)
179
+ Object.assign(out.payload, parsed)
180
+ }
181
+
182
+ return out
183
+ }
184
+
185
+ function isResponseLike (value) {
186
+ return (
187
+ value
188
+ && typeof value === 'object'
189
+ && typeof value.status === 'number'
190
+ && typeof value.text === 'function'
191
+ && typeof value.headers === 'object'
192
+ && typeof value.headers.get === 'function'
193
+ )
194
+ }
195
+
196
+ async function call (args) {
197
+ let parsed
198
+ try {
199
+ parsed = parseCallArgs(args)
200
+ } catch (err) {
201
+ logError({ message: `[@ossy/cli] call: ${err.message}` })
202
+ process.exit(1)
203
+ }
204
+
205
+ if (parsed.help && !parsed.actionId) {
206
+ console.log(buildHelp())
207
+ return
208
+ }
209
+
210
+ if (!parsed.actionId) {
211
+ console.log(buildHelp())
212
+ return
213
+ }
214
+
215
+ const action = ACTION_BY_ID.get(parsed.actionId)
216
+ if (!action) {
217
+ logError({
218
+ message: `[@ossy/cli] call: unknown action "${parsed.actionId}". Run \`ossy call --help\` for the list.`,
219
+ })
220
+ process.exit(1)
221
+ }
222
+
223
+ if (parsed.help) {
224
+ console.log(actionHelp(action))
225
+ return
226
+ }
227
+
228
+ const auth = getAuth({ flag: parsed.authentication })
229
+ const apiUrl = getApiUrl({ flag: parsed.apiUrl })
230
+ const workspaceId = getWorkspaceId({ flag: parsed.workspaceId })
231
+
232
+ const sdk = Sdk.SDK.of({
233
+ apiUrl,
234
+ workspaceId: workspaceId || undefined,
235
+ authorization: auth || undefined,
236
+ })
237
+
238
+ let fileMeta = null
239
+ if (parsed.file) {
240
+ try {
241
+ fileMeta = prepareFileUpload(parsed.file)
242
+ } catch (err) {
243
+ logError({ message: `[@ossy/cli] call: ${err.message}` })
244
+ process.exit(1)
245
+ }
246
+ if (!parsed.explicitPayloadKeys.has('name')) parsed.payload.name = fileMeta.name
247
+ if (!parsed.explicitPayloadKeys.has('type')) parsed.payload.type = fileMeta.type
248
+ if (!parsed.explicitPayloadKeys.has('size')) parsed.payload.size = fileMeta.size
249
+ }
250
+
251
+ let result
252
+ try {
253
+ result = await sdk.makeRequest(action)(parsed.payload)
254
+ } catch (err) {
255
+ if (isResponseLike(err)) {
256
+ const text = await err.text().catch(() => '')
257
+ process.stderr.write(`HTTP ${err.status}${err.statusText ? ' ' + err.statusText : ''}\n`)
258
+ if (text) process.stderr.write(text + '\n')
259
+ process.exit(1)
260
+ }
261
+ if (typeof err === 'string') {
262
+ process.stderr.write(err + '\n')
263
+ process.exit(1)
264
+ }
265
+ if (err && err.message) {
266
+ process.stderr.write(`${err.message}\n`)
267
+ process.exit(1)
268
+ }
269
+ process.stderr.write(JSON.stringify(err) + '\n')
270
+ process.exit(1)
271
+ }
272
+
273
+ if (isResponseLike(result)) {
274
+ if (parsed.raw) {
275
+ const text = await result.text().catch(() => '')
276
+ if (text) process.stdout.write(text.endsWith('\n') ? text : text + '\n')
277
+ } else {
278
+ console.log(JSON.stringify({ status: result.status }, null, 2))
279
+ }
280
+ return
281
+ }
282
+
283
+ if (fileMeta) {
284
+ const uploadUrl = result && result.content && result.content.uploadUrl
285
+ if (uploadUrl) {
286
+ try {
287
+ const body = await readFile(fileMeta.absPath)
288
+ const putRes = await fetch(uploadUrl, {
289
+ method: 'PUT',
290
+ headers: {
291
+ 'Content-Type': fileMeta.type,
292
+ 'Content-Length': String(fileMeta.size),
293
+ },
294
+ body,
295
+ })
296
+ if (!putRes.ok) {
297
+ const t = await putRes.text().catch(() => '')
298
+ process.stderr.write(
299
+ `[@ossy/cli] call: upload PUT failed: HTTP ${putRes.status}${t ? ' — ' + t.slice(0, 200) : ''}\n`
300
+ )
301
+ process.exit(1)
302
+ }
303
+ } catch (err) {
304
+ process.stderr.write(`[@ossy/cli] call: upload PUT failed: ${err.message || err}\n`)
305
+ process.exit(1)
306
+ }
307
+ } else {
308
+ process.stderr.write(
309
+ '[@ossy/cli] call: --file was provided but the response did not include content.uploadUrl; nothing was uploaded.\n'
310
+ )
311
+ }
312
+ }
313
+
314
+ if (parsed.raw) {
315
+ process.stdout.write(
316
+ typeof result === 'string'
317
+ ? (result.endsWith('\n') ? result : result + '\n')
318
+ : JSON.stringify(result) + '\n'
319
+ )
320
+ return
321
+ }
322
+
323
+ console.log(JSON.stringify(result, null, 2))
324
+ }
325
+
326
+ export const handler = call
327
+ export { parseCallArgs, ACTIONS, ACTION_BY_ID, listActionsByDomain, buildHelp, actionHelp }
package/src/cms/cli.js CHANGED
@@ -3,6 +3,7 @@ import { pathToFileURL } from 'url'
3
3
  import arg from 'arg'
4
4
  import { logInfo, logError } from '../log.js'
5
5
  import { resolveAppConfigPath } from '../resolve-app-config-path.js'
6
+ import { getAuth } from '../state.js'
6
7
  import {
7
8
  postResourceTemplates,
8
9
  resolveApiBaseUrlForUpload,
@@ -22,14 +23,13 @@ const upload = (options) => {
22
23
  '--api-url': String,
23
24
  }, { argv: options })
24
25
 
25
- const token =
26
- parsedArgs['--authentication'] || process.env.OSSY_API_KEY
26
+ const token = getAuth({ flag: parsedArgs['--authentication'] })
27
27
  const apiUrlFlag = parsedArgs['--api-url']
28
28
 
29
29
  if (!token) {
30
30
  logError({
31
31
  message:
32
- '[@ossy/cli] No token provided. Use --authentication / -a or set OSSY_API_KEY',
32
+ '[@ossy/cli] No token provided. Use --authentication / -a, set OSSY_API_KEY, or run `ossy auth login`.',
33
33
  })
34
34
  process.exit(1)
35
35
  }
package/src/file.js ADDED
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /** @type {Record<string, string>} */
5
+ const MIME_BY_EXT = {
6
+ '.js': 'application/javascript',
7
+ '.mjs': 'application/javascript',
8
+ '.cjs': 'application/javascript',
9
+ '.css': 'text/css',
10
+ '.html': 'text/html',
11
+ '.htm': 'text/html',
12
+ '.json': 'application/json',
13
+ '.map': 'application/json',
14
+ '.svg': 'image/svg+xml',
15
+ '.png': 'image/png',
16
+ '.jpg': 'image/jpeg',
17
+ '.jpeg': 'image/jpeg',
18
+ '.gif': 'image/gif',
19
+ '.webp': 'image/webp',
20
+ '.ico': 'image/x-icon',
21
+ '.woff': 'font/woff',
22
+ '.woff2': 'font/woff2',
23
+ '.ttf': 'font/ttf',
24
+ '.txt': 'text/plain',
25
+ '.xml': 'application/xml',
26
+ '.webmanifest': 'application/manifest+json',
27
+ '.pdf': 'application/pdf',
28
+ '.mp4': 'video/mp4',
29
+ '.webm': 'video/webm',
30
+ '.mp3': 'audio/mpeg',
31
+ '.wav': 'audio/wav',
32
+ }
33
+
34
+ /**
35
+ * Best-effort MIME type from extension. Falls back to `application/octet-stream`.
36
+ * @param {string} filePath
37
+ * @returns {string}
38
+ */
39
+ export function guessContentType (filePath) {
40
+ const ext = path.extname(filePath).toLowerCase()
41
+ return MIME_BY_EXT[ext] || 'application/octet-stream'
42
+ }
43
+
44
+ /**
45
+ * Stat a local file and derive metadata for an upload payload.
46
+ * Throws if the path doesn't exist or isn't a regular file.
47
+ * @param {string} filePath absolute or cwd-relative
48
+ * @returns {{ absPath: string, name: string, size: number, type: string }}
49
+ */
50
+ export function prepareFileUpload (filePath) {
51
+ if (!filePath || typeof filePath !== 'string') {
52
+ throw new Error('--file: missing path')
53
+ }
54
+ const absPath = path.resolve(filePath)
55
+ let stat
56
+ try {
57
+ stat = fs.statSync(absPath)
58
+ } catch (err) {
59
+ if (err && err.code === 'ENOENT') {
60
+ throw new Error(`--file: not found: ${filePath}`)
61
+ }
62
+ throw new Error(`--file: cannot stat ${filePath}: ${err.message || err}`)
63
+ }
64
+ if (!stat.isFile()) {
65
+ throw new Error(`--file: not a regular file: ${filePath}`)
66
+ }
67
+ return {
68
+ absPath,
69
+ name: path.basename(absPath),
70
+ size: stat.size,
71
+ type: guessContentType(absPath),
72
+ }
73
+ }
package/src/index.js CHANGED
@@ -3,10 +3,13 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { build } from '@ossy/app'
6
+ import * as Auth from './auth/cli.js'
7
+ import * as Call from './call/cli.js'
6
8
  import * as Cms from './cms/cli.js'
7
9
  import * as Init from './init/cli.js'
8
10
  import { publish } from './publish/cli.js'
9
11
  import * as Registry from './registry/cli.js'
12
+ import * as Workspace from './workspace/cli.js'
10
13
 
11
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
15
  const pkg = JSON.parse(
@@ -19,12 +22,23 @@ Command line tool for the Ossy platform.
19
22
  Usage:
20
23
  ossy <command> [options]
21
24
 
22
- Commands:
25
+ Local utilities:
26
+ auth <subcommand> Sign in / out and check status: login | logout | status
27
+ workspace <subcommand> Manage active workspace: use | list | current
23
28
  init [dir] Scaffold a new Ossy app
24
29
  build Production build of the app in the current directory
25
- publish Deploy a site and upload templates / site artifacts
26
- cms <subcommand> Resource template workflows: upload | validate
27
- registry <subcommand> Container registry helpers: ecr-push-credentials
30
+ cms validate Validate src/config.js (workspaceId + resourceTemplates)
31
+
32
+ Action dispatcher (any Ossy SDK action, no per-action wiring needed):
33
+ call <action.id> Run \`ossy call --help\` for the full catalog
34
+
35
+ Workflows (wrap one or more actions with extra ergonomics):
36
+ cms upload Upload resource templates from src/config.js
37
+ (wraps workspaces.import-resource-templates)
38
+ registry <subcommand> ecr-push-credentials (wraps registry.ecr-push-credentials,
39
+ adds --format github-actions and password masking)
40
+ publish Deploy via @ossy/deployment-tools, then upload
41
+ resource templates and site build artifacts
28
42
 
29
43
  Options:
30
44
  -h, --help Show this help, or for a command: ossy <command> --help
@@ -32,6 +46,22 @@ Options:
32
46
  `
33
47
 
34
48
  const SUBCOMMAND_HELP = {
49
+ auth: `ossy auth <subcommand>
50
+ Subcommands:
51
+ login Save an Ossy API token under ~/.config/ossy/credentials.json
52
+ logout Delete the saved token
53
+ status Print logged-in user, active workspace, and config paths
54
+
55
+ Options (login):
56
+ -t, --token Ossy API JWT (otherwise prompts in a TTY)
57
+ --api-url API base URL (default: https://api.ossy.se/api/v0)
58
+ `,
59
+ workspace: `ossy workspace <subcommand>
60
+ Subcommands:
61
+ use <id> Set the active workspace (saved to ~/.config/ossy/config.json)
62
+ list List workspaces the current user can access
63
+ current Print the active workspace id
64
+ `,
35
65
  init: `ossy init [dir]
36
66
  Scaffold a new Ossy app in [dir] (defaults to current directory).
37
67
  Creates src/home.page.jsx, src/config.js, and package.json (if missing).
@@ -41,11 +71,15 @@ const SUBCOMMAND_HELP = {
41
71
  See the @ossy/app README for build options.
42
72
  `,
43
73
  publish: `ossy publish [options]
44
- Deploy via @ossy/deployment-tools, then optionally upload resource templates
45
- and site artifacts.
74
+ Multi-step workflow: deploy via @ossy/deployment-tools (temporary), then
75
+ optionally upload resource templates and site build artifacts. The deploy
76
+ step is intended to go away once the platform reacts to artifact events;
77
+ the CMS upload steps are equivalent to:
78
+ workspaces.import-resource-templates (resource templates)
79
+ /site-artifacts/presign-batch + /commit-batch (site artifacts)
46
80
 
47
81
  Options:
48
- -a, --authentication Ossy API JWT (or set OSSY_API_KEY)
82
+ -a, --authentication Ossy API JWT (or set OSSY_API_KEY, or run \`ossy auth login\`)
49
83
  -d, --domain Site domain
50
84
  -p, --platform Target deployment platform
51
85
  -c, --config Path to src/config.js
@@ -60,10 +94,11 @@ Options:
60
94
  cms: `ossy cms <subcommand>
61
95
  Subcommands:
62
96
  upload Upload resource templates from src/config.js to the workspace
97
+ (wraps the SDK action workspaces.import-resource-templates)
63
98
  validate Validate ossy app config and resource templates locally
64
99
 
65
100
  Options (upload):
66
- -a, --authentication Ossy API JWT (or set OSSY_API_KEY)
101
+ -a, --authentication Ossy API JWT (or set OSSY_API_KEY, or run \`ossy auth login\`)
67
102
  -c, --config Path to src/config.js
68
103
  --api-url API base URL for CMS calls
69
104
 
@@ -73,10 +108,12 @@ Options (validate):
73
108
  registry: `ossy registry <subcommand>
74
109
  Subcommands:
75
110
  ecr-push-credentials Fetch a short-lived ECR password for docker login
111
+ (wraps the SDK action registry.ecr-push-credentials;
112
+ adds --format github-actions and password masking)
76
113
 
77
114
  Options:
78
- -a, --authentication Ossy API JWT (or set OSSY_API_KEY)
79
- --workspace-id Workspace id
115
+ -a, --authentication Ossy API JWT (or set OSSY_API_KEY, or run \`ossy auth login\`)
116
+ -w, --workspace-id Workspace id (or set OSSY_WORKSPACE_ID, or run \`ossy workspace use\`)
80
117
  --format json | github-actions
81
118
  `,
82
119
  }
@@ -102,6 +139,18 @@ if (restArgs.some(isHelpFlag) && SUBCOMMAND_HELP[command]) {
102
139
  }
103
140
 
104
141
  const run = async () => {
142
+ if (command === 'auth') {
143
+ await Auth.handler(restArgs)
144
+ return
145
+ }
146
+ if (command === 'workspace') {
147
+ await Workspace.handler(restArgs)
148
+ return
149
+ }
150
+ if (command === 'call') {
151
+ await Call.handler(restArgs)
152
+ return
153
+ }
105
154
  if (command === 'registry') {
106
155
  await Registry.handler(restArgs)
107
156
  return
@@ -10,6 +10,7 @@ import {
10
10
  import { maybeUploadResourceTemplatesAfterPublish } from './resource-templates-after-publish.js'
11
11
  import { maybeUploadSiteArtifactsAfterPublish } from './site-artifacts-after-publish.js'
12
12
  import { requireCmsAuthentication } from '../cms/upload-resource-templates.js'
13
+ import { getAuth } from '../state.js'
13
14
 
14
15
  const DEPLOYMENT_TOOLS = '@ossy/deployment-tools'
15
16
 
@@ -64,14 +65,14 @@ export async function publish (options) {
64
65
  let apiToken
65
66
  try {
66
67
  apiToken = requireCmsAuthentication(
67
- parsedArgs['--authentication'] || process.env.OSSY_API_KEY,
68
+ getAuth({ flag: parsedArgs['--authentication'] }),
68
69
  'publish'
69
70
  )
70
71
  } catch (e) {
71
72
  logError({
72
73
  message:
73
74
  e?.message
74
- || '[@ossy/cli] publish: pass --authentication (-a) or set OSSY_API_KEY (Ossy API JWT).',
75
+ || '[@ossy/cli] publish: pass --authentication (-a), set OSSY_API_KEY, or run `ossy auth login`.',
75
76
  })
76
77
  process.exit(1)
77
78
  }
@@ -8,38 +8,11 @@ import {
8
8
  requireCmsAuthentication,
9
9
  resolveApiBaseUrlForUpload,
10
10
  } from '../cms/upload-resource-templates.js'
11
+ import { guessContentType } from '../file.js'
11
12
 
12
13
  const MAX_FILES = 200
13
14
 
14
- /** @type {Record<string, string>} */
15
- const MIME_BY_EXT = {
16
- '.js': 'application/javascript',
17
- '.mjs': 'application/javascript',
18
- '.cjs': 'application/javascript',
19
- '.css': 'text/css',
20
- '.html': 'text/html',
21
- '.htm': 'text/html',
22
- '.json': 'application/json',
23
- '.map': 'application/json',
24
- '.svg': 'image/svg+xml',
25
- '.png': 'image/png',
26
- '.jpg': 'image/jpeg',
27
- '.jpeg': 'image/jpeg',
28
- '.gif': 'image/gif',
29
- '.webp': 'image/webp',
30
- '.ico': 'image/x-icon',
31
- '.woff': 'font/woff',
32
- '.woff2': 'font/woff2',
33
- '.ttf': 'font/ttf',
34
- '.txt': 'text/plain',
35
- '.xml': 'application/xml',
36
- '.webmanifest': 'application/manifest+json',
37
- }
38
-
39
- export function guessContentType (filePath) {
40
- const ext = path.extname(filePath).toLowerCase()
41
- return MIME_BY_EXT[ext] || 'application/octet-stream'
42
- }
15
+ export { guessContentType }
43
16
 
44
17
  /**
45
18
  * @param {string} buildDir absolute path to `build/` output
@@ -1,6 +1,7 @@
1
1
  import arg from 'arg'
2
2
  import { appendFileSync } from 'fs'
3
3
  import { requireCmsAuthentication } from '../cms/upload-resource-templates.js'
4
+ import { getAuth, getWorkspaceId } from '../state.js'
4
5
 
5
6
  const DEFAULT_API_URL = 'https://api.ossy.se/api/v0'
6
7
 
@@ -25,17 +26,17 @@ export async function ecrPushCredentials (options) {
25
26
  let token
26
27
  try {
27
28
  token = requireCmsAuthentication(
28
- parsed['--authentication'] || process.env.OSSY_API_KEY,
29
+ getAuth({ flag: parsed['--authentication'] }),
29
30
  'registry ecr-push-credentials'
30
31
  )
31
32
  } catch (e) {
32
- console.error(e?.message || '[@ossy/cli] registry ecr-push-credentials: need --authentication (-a) or OSSY_API_KEY')
33
+ console.error(e?.message || '[@ossy/cli] registry ecr-push-credentials: need --authentication (-a), OSSY_API_KEY, or `ossy auth login`')
33
34
  process.exit(1)
34
35
  }
35
36
 
36
- const workspaceId = parsed['--workspace-id'] || process.env.OSSY_WORKSPACE_ID || ''
37
- if (!workspaceId.trim()) {
38
- console.error('[@ossy/cli] registry ecr-push-credentials: pass --workspace-id (-w) or set OSSY_WORKSPACE_ID')
37
+ const workspaceId = getWorkspaceId({ flag: parsed['--workspace-id'] })
38
+ if (!workspaceId) {
39
+ console.error('[@ossy/cli] registry ecr-push-credentials: pass --workspace-id (-w), set OSSY_WORKSPACE_ID, or run `ossy workspace use <id>`')
39
40
  process.exit(1)
40
41
  }
41
42
 
@@ -46,7 +47,7 @@ export async function ecrPushCredentials (options) {
46
47
  method: 'POST',
47
48
  headers: {
48
49
  authorization: token,
49
- workspaceId: workspaceId.trim(),
50
+ workspaceId,
50
51
  'content-type': 'application/json',
51
52
  },
52
53
  body: '{}',
package/src/state.js ADDED
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const DEFAULT_API_URL = 'https://api.ossy.se/api/v0'
6
+
7
+ function configDir () {
8
+ const xdg = process.env.XDG_CONFIG_HOME
9
+ if (xdg && xdg.trim()) return path.join(xdg.trim(), 'ossy')
10
+ return path.join(os.homedir(), '.config', 'ossy')
11
+ }
12
+
13
+ function ensureDir () {
14
+ const dir = configDir()
15
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
16
+ return dir
17
+ }
18
+
19
+ function credentialsPath () { return path.join(configDir(), 'credentials.json') }
20
+ function configPath () { return path.join(configDir(), 'config.json') }
21
+
22
+ /** @returns {{ apiUrl?: string, token?: string } | null} */
23
+ export function readCredentials () {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'))
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** @param {{ apiUrl?: string, token: string }} creds */
32
+ export function writeCredentials (creds) {
33
+ ensureDir()
34
+ fs.writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 })
35
+ }
36
+
37
+ export function deleteCredentials () {
38
+ try { fs.unlinkSync(credentialsPath()) } catch { /* not logged in */ }
39
+ }
40
+
41
+ /** @returns {{ workspaceId?: string } | null} */
42
+ export function readConfig () {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(configPath(), 'utf8'))
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ /** @param {{ workspaceId?: string }} cfg */
51
+ export function writeConfig (cfg) {
52
+ ensureDir()
53
+ fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2))
54
+ }
55
+
56
+ function stripBearer (token) {
57
+ return String(token ?? '').trim().replace(/^Bearer\s+/i, '').trim()
58
+ }
59
+
60
+ /**
61
+ * Resolve the Ossy API JWT used for authenticated requests.
62
+ * Order: explicit flag > OSSY_API_KEY env > saved credentials.
63
+ * @param {{ flag?: string }} [opts]
64
+ * @returns {string | null}
65
+ */
66
+ export function getAuth ({ flag } = {}) {
67
+ const fromFlag = stripBearer(flag)
68
+ if (fromFlag) return fromFlag
69
+ const fromEnv = stripBearer(process.env.OSSY_API_KEY)
70
+ if (fromEnv) return fromEnv
71
+ const fromFile = stripBearer(readCredentials()?.token)
72
+ if (fromFile) return fromFile
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Order: explicit absolute flag > OSSY_API_URL env > saved credentials > default.
78
+ * Relative paths in the flag are ignored (matches existing publish behavior).
79
+ * @param {{ flag?: string }} [opts]
80
+ * @returns {string}
81
+ */
82
+ export function getApiUrl ({ flag } = {}) {
83
+ if (flag && /^https?:\/\//i.test(String(flag).trim())) {
84
+ return String(flag).trim().replace(/\/$/, '')
85
+ }
86
+ const fromEnv = String(process.env.OSSY_API_URL ?? '').trim()
87
+ if (fromEnv) return fromEnv.replace(/\/$/, '')
88
+ const fromFile = String(readCredentials()?.apiUrl ?? '').trim()
89
+ if (/^https?:\/\//i.test(fromFile)) return fromFile.replace(/\/$/, '')
90
+ return DEFAULT_API_URL
91
+ }
92
+
93
+ /**
94
+ * Order: explicit flag > OSSY_WORKSPACE_ID env > saved active workspace.
95
+ * Project-scoped commands (publish/cms) keep reading from src/config.js separately.
96
+ * @param {{ flag?: string }} [opts]
97
+ * @returns {string | null}
98
+ */
99
+ export function getWorkspaceId ({ flag } = {}) {
100
+ const fromFlag = String(flag ?? '').trim()
101
+ if (fromFlag) return fromFlag
102
+ const fromEnv = String(process.env.OSSY_WORKSPACE_ID ?? '').trim()
103
+ if (fromEnv) return fromEnv
104
+ const fromFile = String(readConfig()?.workspaceId ?? '').trim()
105
+ if (fromFile) return fromFile
106
+ return null
107
+ }
108
+
109
+ export function describeStatePaths () {
110
+ return {
111
+ dir: configDir(),
112
+ credentials: credentialsPath(),
113
+ config: configPath(),
114
+ }
115
+ }
@@ -0,0 +1,84 @@
1
+ import { logInfo, logError } from '../log.js'
2
+ import {
3
+ readConfig,
4
+ writeConfig,
5
+ getAuth,
6
+ getApiUrl,
7
+ getWorkspaceId,
8
+ } from '../state.js'
9
+
10
+ function ensureAuth () {
11
+ const auth = getAuth()
12
+ if (!auth) {
13
+ logError({
14
+ message: '[@ossy/cli] Not logged in. Run `ossy auth login` first.',
15
+ })
16
+ process.exit(1)
17
+ }
18
+ return auth
19
+ }
20
+
21
+ function use (options) {
22
+ const id = options[0]
23
+ if (!id) {
24
+ logError({ message: '[@ossy/cli] workspace use: pass a workspace id.' })
25
+ process.exit(1)
26
+ }
27
+ writeConfig({ ...(readConfig() || {}), workspaceId: id })
28
+ logInfo({ message: `[@ossy/cli] Active workspace: ${id}` })
29
+ }
30
+
31
+ async function list () {
32
+ const auth = ensureAuth()
33
+ const apiUrl = getApiUrl()
34
+
35
+ let res
36
+ try {
37
+ res = await fetch(`${apiUrl}/workspaces`, {
38
+ headers: { Authorization: auth, 'Content-Type': 'application/json' },
39
+ })
40
+ } catch (error) {
41
+ logError({ message: `[@ossy/cli] workspace list: could not reach ${apiUrl}`, error })
42
+ process.exit(1)
43
+ }
44
+
45
+ if (!res.ok) {
46
+ logError({
47
+ message: `[@ossy/cli] workspace list: HTTP ${res.status} from ${apiUrl}/workspaces`,
48
+ })
49
+ process.exit(1)
50
+ }
51
+
52
+ const workspaces = await res.json().catch(() => null)
53
+ if (!Array.isArray(workspaces) || workspaces.length === 0) {
54
+ console.log('No workspaces found.')
55
+ return
56
+ }
57
+
58
+ const active = getWorkspaceId()
59
+ for (const w of workspaces) {
60
+ const marker = w.id === active ? '* ' : ' '
61
+ console.log(`${marker}${w.id}\t${w.name || ''}`)
62
+ }
63
+ }
64
+
65
+ function current () {
66
+ const id = getWorkspaceId()
67
+ if (!id) {
68
+ console.log('(no active workspace)')
69
+ return
70
+ }
71
+ console.log(id)
72
+ }
73
+
74
+ export async function handler (args) {
75
+ const [sub, ...rest] = args
76
+ if (sub === 'use') return use(rest)
77
+ if (sub === 'list') return await list()
78
+ if (sub === 'current') return current()
79
+ logError({
80
+ message:
81
+ '[@ossy/cli] workspace: unknown subcommand. Use: ossy workspace use <id> | list | current',
82
+ })
83
+ process.exit(1)
84
+ }