@ossy/cli 1.16.9 → 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
@@ -1,21 +1,108 @@
1
1
  # @ossy/cli
2
2
 
3
- Unified CLI for the Ossy platform: app build and CMS workflows.
3
+ Unified CLI for the Ossy platform.
4
4
 
5
- ## Commands
5
+ ## Install
6
6
 
7
- | Command | Description |
8
- |---------|-------------|
9
- | `init [dir]` | Scaffold a new Ossy app (default: current directory) |
10
- | `build` | Production build |
11
- | `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 |
12
- | `cms upload` | Upload resource templates only (same API as publish’s upload step) |
13
- | `cms validate` | Validate ossy config and resource templates |
7
+ ```bash
8
+ npm install -g @ossy/cli
9
+ ```
10
+
11
+ After install, the `ossy` command is on your PATH:
12
+
13
+ ```bash
14
+ ossy --version
15
+ ossy --help
16
+ ossy <command> --help
17
+ ```
18
+
19
+ For one-off use without installing, you can still run `npx @ossy/cli <command>`.
20
+
21
+ ## How the CLI is organized
22
+
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.
14
101
 
15
102
  ## App: build
16
103
 
17
104
  ```bash
18
- npx @ossy/cli build
105
+ ossy build
19
106
  ```
20
107
 
21
108
  Options: e.g. `--config` for `src/config.js`. See `packages/app/README.md` for app build behavior.
@@ -24,6 +111,8 @@ Options: e.g. `--config` for `src/config.js`. See `packages/app/README.md` for a
24
111
 
25
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.
26
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
+
27
116
  ### Temporary: no execution of `src/config.js` for CMS steps
28
117
 
29
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).
@@ -51,7 +140,7 @@ Container deploys assume **Amazon ECR** in **`deployments.json`** (`registry` li
51
140
  cd packages/my-website
52
141
  export OSSY_API_KEY=<ossy-api-jwt> # or pass --authentication <ossy-api-jwt>
53
142
 
54
- npx @ossy/cli publish \
143
+ ossy publish \
55
144
  --platforms-path ../infrastructure/platforms.json \
56
145
  --deployments-path "../infrastructure/deployments/**/*.json"
57
146
  ```
@@ -74,10 +163,16 @@ Requires network access so `npx` can run `@ossy/deployment-tools`.
74
163
 
75
164
  Upload resource templates to your workspace so they can be used in the UI.
76
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
+
77
172
  Prefer **`--authentication` / `-a`** for the **Ossy API JWT** (same as **`publish`**); it matches **`OSSY_API_KEY`** in CI.
78
173
 
79
174
  ```bash
80
- npx @ossy/cli cms upload --authentication <ossy-api-jwt> --config src/config.js
175
+ ossy cms upload --authentication <ossy-api-jwt> --config src/config.js
81
176
  # optional: --api-url https://api.ossy.se/api/v0 (or set OSSY_API_URL)
82
177
  # In CI you may omit --authentication when OSSY_API_KEY is set
83
178
  ```
@@ -119,7 +214,7 @@ jobs:
119
214
  Validate an ossy config file before uploading:
120
215
 
121
216
  ```bash
122
- npx @ossy/cli cms validate --config src/config.js
217
+ ossy cms validate --config src/config.js
123
218
  ```
124
219
 
125
220
  When **`--config`** is omitted, **`./src/config.js`** is used if it exists.
@@ -132,13 +227,32 @@ When **`--config`** is omitted, **`./src/config.js`** is used if it exists.
132
227
  | --config, -c | App config (`workspaceId`, `resourceTemplates`, …) | Optional if `./src/config.js` exists |
133
228
  | --api-url | API base URL for upload (`…/api/v0`) | No |
134
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
+
135
249
  ## init
136
250
 
137
251
  Scaffold a new Ossy app:
138
252
 
139
253
  ```bash
140
- npx @ossy/cli init
141
- npx @ossy/cli init my-app
254
+ ossy init
255
+ ossy init my-app
142
256
  ```
143
257
 
144
258
  Creates `src/home.page.jsx`, `src/config.js`, and `package.json` (if missing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/cli",
3
- "version": "1.16.9",
3
+ "version": "1.16.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ossy-se/packages.git"
@@ -15,10 +15,13 @@
15
15
  },
16
16
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
17
17
  "license": "MIT",
18
- "bin": "./src/index.js",
18
+ "bin": {
19
+ "ossy": "./src/index.js"
20
+ },
19
21
  "dependencies": {
20
22
  "@babel/parser": "^7.28.6",
21
- "@ossy/app": "^1.16.9",
23
+ "@ossy/app": "^1.16.11",
24
+ "@ossy/sdk": "^1.16.11",
22
25
  "arg": "^5.0.2",
23
26
  "glob": "^10.3.10"
24
27
  },
@@ -30,5 +33,5 @@
30
33
  "/src",
31
34
  "README.md"
32
35
  ],
33
- "gitHead": "be7612ac40cc49e027b0e417938cda738e19cd58"
36
+ "gitHead": "17a1a491a5239502f989b3745dab7a6c8ae17076"
34
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
@@ -1,20 +1,156 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
2
5
  import { build } from '@ossy/app'
6
+ import * as Auth from './auth/cli.js'
7
+ import * as Call from './call/cli.js'
3
8
  import * as Cms from './cms/cli.js'
4
9
  import * as Init from './init/cli.js'
5
10
  import { publish } from './publish/cli.js'
6
11
  import * as Registry from './registry/cli.js'
12
+ import * as Workspace from './workspace/cli.js'
7
13
 
8
- const [,, command, ...restArgs] = process.argv
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
15
+ const pkg = JSON.parse(
16
+ fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
17
+ )
9
18
 
10
- if (!command) {
11
- console.error(
12
- '[@ossy/cli] No command provided. Usage: ossy build [--worker] | publish | registry <subcommand> | init | cms <subcommand>'
13
- )
14
- process.exit(1)
19
+ const HELP = `ossy ${pkg.version}
20
+ Command line tool for the Ossy platform.
21
+
22
+ Usage:
23
+ ossy <command> [options]
24
+
25
+ Local utilities:
26
+ auth <subcommand> Sign in / out and check status: login | logout | status
27
+ workspace <subcommand> Manage active workspace: use | list | current
28
+ init [dir] Scaffold a new Ossy app
29
+ build Production build of the app in the current directory
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
42
+
43
+ Options:
44
+ -h, --help Show this help, or for a command: ossy <command> --help
45
+ -v, --version Print the installed version
46
+ `
47
+
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
+ `,
65
+ init: `ossy init [dir]
66
+ Scaffold a new Ossy app in [dir] (defaults to current directory).
67
+ Creates src/home.page.jsx, src/config.js, and package.json (if missing).
68
+ `,
69
+ build: `ossy build
70
+ Run the production build for the app in the current directory.
71
+ See the @ossy/app README for build options.
72
+ `,
73
+ publish: `ossy publish [options]
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)
80
+
81
+ Options:
82
+ -a, --authentication Ossy API JWT (or set OSSY_API_KEY, or run \`ossy auth login\`)
83
+ -d, --domain Site domain
84
+ -p, --platform Target deployment platform
85
+ -c, --config Path to src/config.js
86
+ --platforms-path Path to platforms.json
87
+ --deployments-path Glob for deployments JSON files
88
+ --all Deploy all sites for the platform
89
+ --skip-resource-templates Skip post-deploy resource-template upload
90
+ --skip-site-artifacts Skip post-deploy site-artifact upload
91
+ --site-artifacts-build-dir Override the site build directory
92
+ --api-url API base URL for CMS calls
93
+ `,
94
+ cms: `ossy cms <subcommand>
95
+ Subcommands:
96
+ upload Upload resource templates from src/config.js to the workspace
97
+ (wraps the SDK action workspaces.import-resource-templates)
98
+ validate Validate ossy app config and resource templates locally
99
+
100
+ Options (upload):
101
+ -a, --authentication Ossy API JWT (or set OSSY_API_KEY, or run \`ossy auth login\`)
102
+ -c, --config Path to src/config.js
103
+ --api-url API base URL for CMS calls
104
+
105
+ Options (validate):
106
+ -c, --config Path to src/config.js
107
+ `,
108
+ registry: `ossy registry <subcommand>
109
+ Subcommands:
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)
113
+
114
+ Options:
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\`)
117
+ --format json | github-actions
118
+ `,
119
+ }
120
+
121
+ const isHelpFlag = (a) => a === '--help' || a === '-h'
122
+ const isVersionFlag = (a) => a === '--version' || a === '-v'
123
+
124
+ const [, , command, ...restArgs] = process.argv
125
+
126
+ if (command === undefined || isHelpFlag(command)) {
127
+ console.log(HELP)
128
+ process.exit(0)
129
+ }
130
+
131
+ if (isVersionFlag(command)) {
132
+ console.log(pkg.version)
133
+ process.exit(0)
134
+ }
135
+
136
+ if (restArgs.some(isHelpFlag) && SUBCOMMAND_HELP[command]) {
137
+ console.log(SUBCOMMAND_HELP[command])
138
+ process.exit(0)
15
139
  }
16
140
 
17
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
+ }
18
154
  if (command === 'registry') {
19
155
  await Registry.handler(restArgs)
20
156
  return
@@ -36,6 +172,7 @@ const run = async () => {
36
172
  return
37
173
  }
38
174
  console.error(`[@ossy/cli] Unknown command: ${command}`)
175
+ console.error('Run `ossy --help` for available commands.')
39
176
  process.exit(1)
40
177
  }
41
178
 
@@ -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
+ }