@ossy/cli 1.16.10 → 1.17.3

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.
@@ -0,0 +1,206 @@
1
+ import path from 'path'
2
+ import { existsSync } from 'fs'
3
+ import arg from 'arg'
4
+ import * as Sdk from '@ossy/sdk'
5
+ import { logInfo, logError } from '../log.js'
6
+ import { resolveAppConfigPath } from '../resolve-app-config-path.js'
7
+ import { getAuth } from '../state.js'
8
+ import { walk } from '../upload-dir/cli.js'
9
+ import { prepareFileUpload, uploadFileToUrl } from '../file.js'
10
+ import {
11
+ postResourceTemplates,
12
+ requireAppAuthentication,
13
+ resolveApiBaseUrlForUpload,
14
+ } from './upload-resource-templates.js'
15
+ import { readAppConfig } from './load-config.js'
16
+
17
+ function normaliseLoc (loc) {
18
+ let s = loc.replace(/\/+$/, '')
19
+ if (!s.startsWith('/')) s = '/' + s
20
+ return s
21
+ }
22
+
23
+ export async function publish (options) {
24
+ const parsedArgs = arg({
25
+ '--authentication': String,
26
+ '-a': '--authentication',
27
+ '--config': String,
28
+ '-c': '--config',
29
+ '--build-dir': String,
30
+ '--build-dest': String,
31
+ '--skip-resource-templates': Boolean,
32
+ '--api-url': String,
33
+ }, { argv: options })
34
+
35
+ const configFlag = parsedArgs['--config']
36
+ const configPath = resolveAppConfigPath(configFlag)
37
+
38
+ if (!configPath || !existsSync(configPath)) {
39
+ logError({
40
+ message: configFlag
41
+ ? `[@ossy/cli] app publish: config not found: ${configFlag}`
42
+ : '[@ossy/cli] app publish: no config file. Pass --config (-c) or add src/config.js.',
43
+ })
44
+ process.exit(1)
45
+ }
46
+
47
+ const config = readAppConfig(configPath)
48
+ const { workspaceId, apiUrl: configApiUrl, domain, resourceTemplates } = config
49
+
50
+ if (!workspaceId) {
51
+ logError({ message: '[@ossy/cli] app publish: no workspaceId in config' })
52
+ process.exit(1)
53
+ }
54
+
55
+ const apiBaseUrl = resolveApiBaseUrlForUpload({
56
+ flag: parsedArgs['--api-url'],
57
+ envVar: process.env.OSSY_API_URL,
58
+ configApiUrl,
59
+ })
60
+
61
+ let authToken
62
+ try {
63
+ authToken = requireAppAuthentication(
64
+ getAuth({ flag: parsedArgs['--authentication'] }),
65
+ 'publish'
66
+ )
67
+ } catch (e) {
68
+ logError({ message: e?.message || '[@ossy/cli] app publish: authentication required.' })
69
+ process.exit(1)
70
+ }
71
+
72
+ // Step 1: upload resource templates
73
+ if (!parsedArgs['--skip-resource-templates']) {
74
+ if (Array.isArray(resourceTemplates) && resourceTemplates.length > 0) {
75
+ logInfo({ message: '[@ossy/cli] app publish: uploading resource templates…' })
76
+ const res = await postResourceTemplates({
77
+ apiBaseUrl,
78
+ token: authToken,
79
+ workspaceId,
80
+ resourceTemplates,
81
+ })
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => '')
84
+ logError({
85
+ message: `[@ossy/cli] app publish: resource template upload failed: HTTP ${res.status}${text ? ` — ${text.slice(0, 200)}` : ''}`,
86
+ })
87
+ process.exit(1)
88
+ }
89
+ logInfo({ message: '[@ossy/cli] app publish: resource templates uploaded' })
90
+ } else {
91
+ logInfo({ message: '[@ossy/cli] app publish: no resource templates in config, skipping' })
92
+ }
93
+ }
94
+
95
+ // Step 2: upload build/ to CMS
96
+ const packageRoot = path.join(path.dirname(configPath), '..')
97
+ const buildDir = parsedArgs['--build-dir']
98
+ ? path.resolve(parsedArgs['--build-dir'])
99
+ : path.join(packageRoot, 'build')
100
+
101
+ const remoteDest = parsedArgs['--build-dest']
102
+ ? normaliseLoc(parsedArgs['--build-dest'])
103
+ : domain
104
+ ? `/sites/${domain}`
105
+ : null
106
+
107
+ if (!remoteDest) {
108
+ logError({
109
+ message: '[@ossy/cli] app publish: no domain in config and no --build-dest specified',
110
+ })
111
+ process.exit(1)
112
+ }
113
+
114
+ if (!existsSync(buildDir)) {
115
+ logError({
116
+ message: `[@ossy/cli] app publish: build directory not found: ${buildDir}. Run \`ossy app build\` first.`,
117
+ })
118
+ process.exit(1)
119
+ }
120
+
121
+ const sdk = Sdk.SDK.of({
122
+ apiUrl: apiBaseUrl,
123
+ workspaceId,
124
+ authorization: authToken,
125
+ })
126
+
127
+ const { dirs, files } = walk(buildDir)
128
+ logInfo({
129
+ message: `[@ossy/cli] app publish: uploading build (${files.length} file${files.length === 1 ? '' : 's'}) to ${remoteDest}…`,
130
+ })
131
+
132
+ const createDir = Sdk.ResourcesCreateDirectory
133
+ const uploadAction = Sdk.ResourcesUpload
134
+
135
+ let errors = 0
136
+
137
+ for (const d of dirs) {
138
+ const rel = d.slice(buildDir.length)
139
+ const parent = normaliseLoc(remoteDest + path.dirname(rel))
140
+ const name = path.basename(d)
141
+ try {
142
+ await sdk.makeRequest(createDir)({ location: parent, name })
143
+ } catch (err) {
144
+ const status = err && err.status
145
+ if (status !== 409 && status !== 422) {
146
+ logError({
147
+ message: `[@ossy/cli] app publish: mkdir failed for ${parent}/${name}: ${err?.message ?? err}`,
148
+ })
149
+ errors++
150
+ }
151
+ }
152
+ }
153
+
154
+ for (const f of files) {
155
+ const rel = f.slice(buildDir.length)
156
+ const remoteDir = normaliseLoc(remoteDest + path.dirname(rel))
157
+
158
+ let fileMeta
159
+ try {
160
+ fileMeta = prepareFileUpload(f)
161
+ } catch (err) {
162
+ logError({ message: `[@ossy/cli] app publish: cannot read file ${f}: ${err.message}` })
163
+ errors++
164
+ continue
165
+ }
166
+
167
+ let result
168
+ try {
169
+ result = await sdk.makeRequest(uploadAction)({
170
+ location: remoteDir,
171
+ name: fileMeta.name,
172
+ type: fileMeta.type,
173
+ size: fileMeta.size,
174
+ })
175
+ } catch (err) {
176
+ logError({ message: `[@ossy/cli] app publish: upload failed for ${f}: ${err?.message ?? err}` })
177
+ errors++
178
+ continue
179
+ }
180
+
181
+ const uploadUrl = result?.content?.uploadUrl
182
+ if (!uploadUrl) {
183
+ logError({ message: `[@ossy/cli] app publish: no uploadUrl in response for ${f}` })
184
+ errors++
185
+ continue
186
+ }
187
+
188
+ try {
189
+ await uploadFileToUrl(fileMeta, uploadUrl)
190
+ } catch (err) {
191
+ logError({ message: `[@ossy/cli] app publish: S3 PUT failed for ${f}: ${err.message}` })
192
+ errors++
193
+ }
194
+ }
195
+
196
+ if (errors > 0) {
197
+ logError({
198
+ message: `[@ossy/cli] app publish: done with ${errors} error${errors === 1 ? '' : 's'}`,
199
+ })
200
+ process.exit(1)
201
+ }
202
+
203
+ logInfo({
204
+ message: `[@ossy/cli] app publish: done — ${files.length} file${files.length === 1 ? '' : 's'} uploaded to ${remoteDest}`,
205
+ })
206
+ }
@@ -14,7 +14,7 @@ export function normalizeAuthorizationToken (token) {
14
14
  * @param {string} label e.g. "Resource template upload"
15
15
  * @returns {string} normalized JWT
16
16
  */
17
- export function requireCmsAuthentication (token, label) {
17
+ export function requireAppAuthentication (token, label) {
18
18
  const normalized = normalizeAuthorizationToken(token)
19
19
  if (!normalized) {
20
20
  throw new Error(
@@ -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,311 @@
1
+ import * as Sdk from '@ossy/sdk'
2
+ import { logError } from '../log.js'
3
+ import { getAuth, getApiUrl, getWorkspaceId } from '../state.js'
4
+ import { prepareFileUpload, uploadFileToUrl } from '../file.js'
5
+
6
+ const ACTIONS = Object.values(Sdk).filter(
7
+ (v) =>
8
+ v
9
+ && typeof v === 'object'
10
+ && typeof v.id === 'string'
11
+ && typeof v.endpoint === 'string'
12
+ && typeof v.method === 'string'
13
+ )
14
+
15
+ const ACTION_BY_ID = new Map(ACTIONS.map((a) => [a.id, a]))
16
+
17
+ function listActionsByDomain () {
18
+ const grouped = new Map()
19
+ for (const action of ACTIONS) {
20
+ const domain = action.id.split('.')[0]
21
+ if (!grouped.has(domain)) grouped.set(domain, [])
22
+ grouped.get(domain).push(action.id)
23
+ }
24
+ for (const [, ids] of grouped) ids.sort()
25
+ return new Map([...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)))
26
+ }
27
+
28
+ function buildHelp () {
29
+ const grouped = listActionsByDomain()
30
+ const blocks = []
31
+ for (const [domain, ids] of grouped) {
32
+ blocks.push(` ${domain}\n${ids.map((id) => ` ${id}`).join('\n')}`)
33
+ }
34
+ return `ossy call <action.id> [--key value]... [--json '{...}'] [options]
35
+
36
+ Generic dispatcher for any Ossy API action. Flags become payload fields
37
+ (--first-name -> firstName); --json '{...}' is merged on top.
38
+
39
+ Options:
40
+ --json '{...}' Provide payload as JSON (merged with --key value flags)
41
+ --file <path> Attach a local file: derives name/type/size as defaults
42
+ and PUTs the bytes if the response returns content.uploadUrl
43
+ -w, --workspace-id Override active workspace id (sent as workspaceId header)
44
+ -a, --authentication Override API token (otherwise resolved from state/env)
45
+ --api-url Override API base URL
46
+ --raw Print response body without JSON pretty-printing
47
+
48
+ Per-action help:
49
+ ossy call <action.id> --help
50
+
51
+ Available actions:
52
+
53
+ ${blocks.join('\n\n')}
54
+ `
55
+ }
56
+
57
+ function actionHelp (action) {
58
+ const def = action.payload ? `Default payload:\n ${JSON.stringify(action.payload)}\n\n` : ''
59
+ return `${action.id} ${action.method} ${action.endpoint}
60
+
61
+ ${def}Examples:
62
+ ossy call ${action.id} --key value --other value
63
+ ossy call ${action.id} --json '{"key":"value"}'
64
+ `
65
+ }
66
+
67
+ function kebabToCamel (str) {
68
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
69
+ }
70
+
71
+ const RESERVED_FLAGS_NEEDING_VALUE = new Set([
72
+ '--json',
73
+ '--workspace-id', '-w',
74
+ '--authentication', '-a',
75
+ '--api-url',
76
+ '--file',
77
+ ])
78
+
79
+ function parseCallArgs (argv) {
80
+ const out = {
81
+ actionId: null,
82
+ payload: {},
83
+ workspaceId: null,
84
+ authentication: null,
85
+ apiUrl: null,
86
+ raw: false,
87
+ json: null,
88
+ file: null,
89
+ explicitPayloadKeys: new Set(),
90
+ help: false,
91
+ }
92
+
93
+ let i = 0
94
+ while (i < argv.length) {
95
+ const arg = argv[i]
96
+
97
+ if (arg === '--help' || arg === '-h') {
98
+ out.help = true
99
+ i++
100
+ continue
101
+ }
102
+ if (arg === '--raw') {
103
+ out.raw = true
104
+ i++
105
+ continue
106
+ }
107
+
108
+ // `--key=value` form, including reserved flags
109
+ if (arg.startsWith('--') && arg.includes('=')) {
110
+ const eq = arg.indexOf('=')
111
+ const key = arg.slice(2, eq)
112
+ const value = arg.slice(eq + 1)
113
+ if (key === 'json') out.json = value
114
+ else if (key === 'workspace-id') out.workspaceId = value
115
+ else if (key === 'authentication') out.authentication = value
116
+ else if (key === 'api-url') out.apiUrl = value
117
+ else if (key === 'file') out.file = value
118
+ else {
119
+ const camel = kebabToCamel(key)
120
+ out.payload[camel] = value
121
+ out.explicitPayloadKeys.add(camel)
122
+ }
123
+ i++
124
+ continue
125
+ }
126
+
127
+ // Reserved space-separated flags
128
+ if (RESERVED_FLAGS_NEEDING_VALUE.has(arg)) {
129
+ const value = argv[i + 1]
130
+ if (arg === '--json') out.json = value
131
+ else if (arg === '--workspace-id' || arg === '-w') out.workspaceId = value
132
+ else if (arg === '--authentication' || arg === '-a') out.authentication = value
133
+ else if (arg === '--api-url') out.apiUrl = value
134
+ else if (arg === '--file') out.file = value
135
+ i += 2
136
+ continue
137
+ }
138
+
139
+ // Action.id is the first non-flag positional
140
+ if (!arg.startsWith('-') && out.actionId === null) {
141
+ out.actionId = arg
142
+ i++
143
+ continue
144
+ }
145
+
146
+ // Generic --key (value | true)
147
+ if (arg.startsWith('--')) {
148
+ const key = arg.slice(2)
149
+ const camel = kebabToCamel(key)
150
+ const next = argv[i + 1]
151
+ if (next === undefined || next.startsWith('-')) {
152
+ out.payload[camel] = true
153
+ out.explicitPayloadKeys.add(camel)
154
+ i++
155
+ } else {
156
+ out.payload[camel] = next
157
+ out.explicitPayloadKeys.add(camel)
158
+ i += 2
159
+ }
160
+ continue
161
+ }
162
+
163
+ // Stray positional after the action id — ignore
164
+ i++
165
+ }
166
+
167
+ if (out.json) {
168
+ let parsed
169
+ try {
170
+ parsed = JSON.parse(out.json)
171
+ } catch (e) {
172
+ throw new Error(`--json: invalid JSON (${e.message})`)
173
+ }
174
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
175
+ throw new Error('--json: must be a JSON object')
176
+ }
177
+ for (const k of Object.keys(parsed)) out.explicitPayloadKeys.add(k)
178
+ Object.assign(out.payload, parsed)
179
+ }
180
+
181
+ return out
182
+ }
183
+
184
+ function isResponseLike (value) {
185
+ return (
186
+ value
187
+ && typeof value === 'object'
188
+ && typeof value.status === 'number'
189
+ && typeof value.text === 'function'
190
+ && typeof value.headers === 'object'
191
+ && typeof value.headers.get === 'function'
192
+ )
193
+ }
194
+
195
+ async function call (args) {
196
+ let parsed
197
+ try {
198
+ parsed = parseCallArgs(args)
199
+ } catch (err) {
200
+ logError({ message: `[@ossy/cli] call: ${err.message}` })
201
+ process.exit(1)
202
+ }
203
+
204
+ if (parsed.help && !parsed.actionId) {
205
+ console.log(buildHelp())
206
+ return
207
+ }
208
+
209
+ if (!parsed.actionId) {
210
+ console.log(buildHelp())
211
+ return
212
+ }
213
+
214
+ const action = ACTION_BY_ID.get(parsed.actionId)
215
+ if (!action) {
216
+ logError({
217
+ message: `[@ossy/cli] call: unknown action "${parsed.actionId}". Run \`ossy call --help\` for the list.`,
218
+ })
219
+ process.exit(1)
220
+ }
221
+
222
+ if (parsed.help) {
223
+ console.log(actionHelp(action))
224
+ return
225
+ }
226
+
227
+ const auth = getAuth({ flag: parsed.authentication })
228
+ const apiUrl = getApiUrl({ flag: parsed.apiUrl })
229
+ const workspaceId = getWorkspaceId({ flag: parsed.workspaceId })
230
+
231
+ const sdk = Sdk.SDK.of({
232
+ apiUrl,
233
+ workspaceId: workspaceId || undefined,
234
+ authorization: auth || undefined,
235
+ })
236
+
237
+ let fileMeta = null
238
+ if (parsed.file) {
239
+ try {
240
+ fileMeta = prepareFileUpload(parsed.file)
241
+ } catch (err) {
242
+ logError({ message: `[@ossy/cli] call: ${err.message}` })
243
+ process.exit(1)
244
+ }
245
+ if (!parsed.explicitPayloadKeys.has('name')) parsed.payload.name = fileMeta.name
246
+ if (!parsed.explicitPayloadKeys.has('type')) parsed.payload.type = fileMeta.type
247
+ if (!parsed.explicitPayloadKeys.has('size')) parsed.payload.size = fileMeta.size
248
+ }
249
+
250
+ let result
251
+ try {
252
+ result = await sdk.makeRequest(action)(parsed.payload)
253
+ } catch (err) {
254
+ if (isResponseLike(err)) {
255
+ const text = await err.text().catch(() => '')
256
+ process.stderr.write(`HTTP ${err.status}${err.statusText ? ' ' + err.statusText : ''}\n`)
257
+ if (text) process.stderr.write(text + '\n')
258
+ process.exit(1)
259
+ }
260
+ if (typeof err === 'string') {
261
+ process.stderr.write(err + '\n')
262
+ process.exit(1)
263
+ }
264
+ if (err && err.message) {
265
+ process.stderr.write(`${err.message}\n`)
266
+ process.exit(1)
267
+ }
268
+ process.stderr.write(JSON.stringify(err) + '\n')
269
+ process.exit(1)
270
+ }
271
+
272
+ if (isResponseLike(result)) {
273
+ if (parsed.raw) {
274
+ const text = await result.text().catch(() => '')
275
+ if (text) process.stdout.write(text.endsWith('\n') ? text : text + '\n')
276
+ } else {
277
+ console.log(JSON.stringify({ status: result.status }, null, 2))
278
+ }
279
+ return
280
+ }
281
+
282
+ if (fileMeta) {
283
+ const uploadUrl = result && result.content && result.content.uploadUrl
284
+ if (uploadUrl) {
285
+ try {
286
+ await uploadFileToUrl(fileMeta, uploadUrl)
287
+ } catch (err) {
288
+ process.stderr.write(`[@ossy/cli] call: upload PUT failed: ${err.message || err}\n`)
289
+ process.exit(1)
290
+ }
291
+ } else {
292
+ process.stderr.write(
293
+ '[@ossy/cli] call: --file was provided but the response did not include content.uploadUrl; nothing was uploaded.\n'
294
+ )
295
+ }
296
+ }
297
+
298
+ if (parsed.raw) {
299
+ process.stdout.write(
300
+ typeof result === 'string'
301
+ ? (result.endsWith('\n') ? result : result + '\n')
302
+ : JSON.stringify(result) + '\n'
303
+ )
304
+ return
305
+ }
306
+
307
+ console.log(JSON.stringify(result, null, 2))
308
+ }
309
+
310
+ export const handler = call
311
+ export { parseCallArgs, ACTIONS, ACTION_BY_ID, listActionsByDomain, buildHelp, actionHelp }