@laioutr/cli 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { fetchDeploymentStatus } from '../../api/deploy.js';
3
+ import { formatAttribution } from '../../deploy/attribution.js';
4
+ import DeployBaseCommand from '../../deploy-base-command.js';
5
+ import { formatDetail } from '../../format/detail.js';
6
+ export default class DeployStatus extends DeployBaseCommand {
7
+ static description = 'Show status of a deployment.';
8
+ static enableJsonFlag = true;
9
+ static examples = ['<%= config.bin %> deploy status --project org/project --key orgKey_xxx --deploymentId dep_abc123'];
10
+ static flags = {
11
+ ...DeployBaseCommand.flags,
12
+ deploymentId: Flags.string({
13
+ description: 'Deployment ID',
14
+ required: true,
15
+ }),
16
+ };
17
+ async run() {
18
+ const { flags } = await this.parse(DeployStatus);
19
+ const { organizationSlug, projectSlug } = this.parseProject(flags.project);
20
+ const status = await fetchDeploymentStatus({
21
+ cockpitApiHost: flags.cockpitApiHost,
22
+ apiKey: flags.key,
23
+ organizationSlug,
24
+ projectSlug,
25
+ deploymentId: flags.deploymentId,
26
+ });
27
+ this.log(formatDetail([
28
+ ['Status', status.status],
29
+ ['Environment', status.environment],
30
+ ['Preview', status.previewName],
31
+ ['URL', status.url],
32
+ ['Created by', formatAttribution(status.attribution)],
33
+ ['Created at', status.createdAt],
34
+ ['Stopped at', status.stoppedAt],
35
+ ], { title: `Deployment ${status.deploymentId}` }));
36
+ return status;
37
+ }
38
+ }
@@ -0,0 +1,23 @@
1
+ import DeployBaseCommand from '../../deploy-base-command.js';
2
+ export default class DeployTrigger extends DeployBaseCommand {
3
+ static description: string;
4
+ static enableJsonFlag: boolean;
5
+ static examples: string[];
6
+ static flags: {
7
+ wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ logs: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ preview: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ key: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ cockpitApiHost: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ cwd: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<{
16
+ deploymentId: string;
17
+ status: string;
18
+ environment: string;
19
+ previewName: string | null;
20
+ }>;
21
+ private pollWithSpinner;
22
+ private pollWithLogs;
23
+ }
@@ -0,0 +1,129 @@
1
+ import { Flags, ux } from '@oclif/core';
2
+ import { fetchDeploymentLogs, fetchDeploymentStatus, triggerDeployment } from '../../api/deploy.js';
3
+ import { LogRenderer } from '../../deploy/log-renderer.js';
4
+ import { isTerminalStatus, POLL_INTERVAL_MS, sleep } from '../../deploy/polling.js';
5
+ import DeployBaseCommand from '../../deploy-base-command.js';
6
+ export default class DeployTrigger extends DeployBaseCommand {
7
+ static description = 'Trigger a project deployment.';
8
+ static enableJsonFlag = true;
9
+ static examples = [
10
+ '<%= config.bin %> deploy trigger --project org/project --key orgKey_xxx',
11
+ '<%= config.bin %> deploy trigger --project org/project --key orgKey_xxx --no-wait',
12
+ '<%= config.bin %> deploy trigger --project org/project --key orgKey_xxx --logs',
13
+ '<%= config.bin %> deploy trigger --project org/project --key orgKey_xxx --preview my-feature',
14
+ ];
15
+ static flags = {
16
+ ...DeployBaseCommand.flags,
17
+ wait: Flags.boolean({
18
+ allowNo: true,
19
+ default: true,
20
+ description: 'Wait for deployment to complete',
21
+ }),
22
+ logs: Flags.boolean({
23
+ default: false,
24
+ description: 'Stream build log output instead of spinner',
25
+ }),
26
+ preview: Flags.string({
27
+ description: 'Create a preview deployment with optional name',
28
+ }),
29
+ };
30
+ async run() {
31
+ const { flags } = await this.parse(DeployTrigger);
32
+ const { organizationSlug, projectSlug } = this.parseProject(flags.project);
33
+ const apiOptions = {
34
+ cockpitApiHost: flags.cockpitApiHost,
35
+ apiKey: flags.key,
36
+ organizationSlug,
37
+ projectSlug,
38
+ };
39
+ // A named preview requires a non-empty name — the deploy route rejects an
40
+ // empty `previewName`, and an unnamed preview is not supported by this contract.
41
+ if (flags.preview !== undefined && flags.preview.trim() === '') {
42
+ this.error('--preview requires a non-empty name');
43
+ }
44
+ const isPreview = flags.preview !== undefined;
45
+ const label = isPreview ? `preview deployment for ${flags.project} (${flags.preview})` : `deployment for ${flags.project}`;
46
+ this.log(`Triggering ${label}...`);
47
+ // Trigger
48
+ const result = await triggerDeployment({
49
+ ...apiOptions,
50
+ previewName: flags.preview,
51
+ });
52
+ this.log(`Deployment ID: ${result.deploymentId}`);
53
+ this.log('');
54
+ if (!flags.wait) {
55
+ this.log(`Status: ${result.status}`);
56
+ this.log('');
57
+ this.log('Deployment triggered. Use `laioutr deploy status` to check progress.');
58
+ return result;
59
+ }
60
+ // Poll until terminal
61
+ if (flags.logs) {
62
+ return this.pollWithLogs(apiOptions, result.deploymentId);
63
+ }
64
+ return this.pollWithSpinner(apiOptions, result.deploymentId);
65
+ }
66
+ async pollWithSpinner(apiOptions, deploymentId) {
67
+ let lastStatus = '';
68
+ while (true) {
69
+ const status = await fetchDeploymentStatus({ ...apiOptions, deploymentId });
70
+ if (status.status !== lastStatus) {
71
+ if (lastStatus)
72
+ ux.action.stop();
73
+ if (!isTerminalStatus(status.status)) {
74
+ ux.action.start(`Status: ${status.status}`);
75
+ }
76
+ lastStatus = status.status;
77
+ }
78
+ if (isTerminalStatus(status.status)) {
79
+ ux.action.stop();
80
+ this.log('');
81
+ // `not_available` is a success outcome: the provider does not report
82
+ // live status, so the deployment is promoted straight to live. Only
83
+ // `error`/`canceled` (and any unexpected terminal status) are failures.
84
+ if (status.status === 'success' || status.status === 'not_available') {
85
+ this.log(status.status === 'success' ? '✓ Deployment successful' : '✓ Deployment live (provider does not report build status)');
86
+ if (status.url)
87
+ this.log(` URL: ${status.url}`);
88
+ }
89
+ else {
90
+ this.log(`✗ Deployment ${status.status}`);
91
+ if (status.lastError)
92
+ this.log(` Error: ${status.lastError}`);
93
+ this.exit(1);
94
+ }
95
+ return status;
96
+ }
97
+ await sleep(POLL_INTERVAL_MS);
98
+ }
99
+ }
100
+ async pollWithLogs(apiOptions, deploymentId) {
101
+ const renderer = new LogRenderer();
102
+ let lastStatus = '';
103
+ while (true) {
104
+ const logsResult = await fetchDeploymentLogs({ ...apiOptions, deploymentId });
105
+ if (logsResult.deploymentStatus !== lastStatus) {
106
+ this.log(`── Status: ${logsResult.deploymentStatus} ──`);
107
+ lastStatus = logsResult.deploymentStatus;
108
+ }
109
+ renderer.render(logsResult.lines, (msg) => this.log(msg));
110
+ if (isTerminalStatus(logsResult.deploymentStatus)) {
111
+ this.log('');
112
+ // `success` and `not_available` are both live outcomes (see pollWithSpinner);
113
+ // fetch the final status for the URL. Everything else is a failure.
114
+ if (logsResult.deploymentStatus === 'success' || logsResult.deploymentStatus === 'not_available') {
115
+ const finalStatus = await fetchDeploymentStatus({ ...apiOptions, deploymentId });
116
+ this.log(logsResult.deploymentStatus === 'success' ?
117
+ '✓ Deployment successful'
118
+ : '✓ Deployment live (provider does not report build status)');
119
+ if (finalStatus.url)
120
+ this.log(` URL: ${finalStatus.url}`);
121
+ return finalStatus;
122
+ }
123
+ this.log(`✗ Deployment ${logsResult.deploymentStatus}`);
124
+ this.exit(1);
125
+ }
126
+ await sleep(POLL_INTERVAL_MS);
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,12 @@
1
+ import type { attributionSchema } from './validation.js';
2
+ import type { z } from 'zod/v4';
3
+ type Attribution = z.infer<typeof attributionSchema>;
4
+ /**
5
+ * Renders the deploy read-path "Created by" prose. This display string is
6
+ * derived here, never stored server-side.
7
+ *
8
+ * `redacted` wins over `agentName`: a redacted row's `displayName` is scrubbed
9
+ * server-side, so it must never be rendered — not even with an agent suffix.
10
+ */
11
+ export declare function formatAttribution(attribution: Attribution | null): string;
12
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Renders the deploy read-path "Created by" prose. This display string is
3
+ * derived here, never stored server-side.
4
+ *
5
+ * `redacted` wins over `agentName`: a redacted row's `displayName` is scrubbed
6
+ * server-side, so it must never be rendered — not even with an agent suffix.
7
+ */
8
+ export function formatAttribution(attribution) {
9
+ if (!attribution)
10
+ return 'Unknown';
11
+ if (attribution.redacted)
12
+ return 'Deleted user';
13
+ if (attribution.agentName)
14
+ return `${attribution.displayName} (via ${attribution.agentName})`;
15
+ return attribution.displayName;
16
+ }
@@ -0,0 +1,13 @@
1
+ import type { buildLogLineSchema } from './validation.js';
2
+ import type { z } from 'zod/v4';
3
+ type BuildLogLine = z.infer<typeof buildLogLineSchema>;
4
+ export declare class LogRenderer {
5
+ private lastIndex;
6
+ /**
7
+ * Renders new log lines since the last call.
8
+ * Returns the number of new lines rendered.
9
+ */
10
+ render(lines: BuildLogLine[] | null, log: (msg: string) => void): number;
11
+ reset(): void;
12
+ }
13
+ export {};
@@ -0,0 +1,20 @@
1
+ export class LogRenderer {
2
+ lastIndex = 0;
3
+ /**
4
+ * Renders new log lines since the last call.
5
+ * Returns the number of new lines rendered.
6
+ */
7
+ render(lines, log) {
8
+ if (!lines)
9
+ return 0;
10
+ const newLines = lines.slice(this.lastIndex);
11
+ for (const line of newLines) {
12
+ log(line.text);
13
+ }
14
+ this.lastIndex = lines.length;
15
+ return newLines.length;
16
+ }
17
+ reset() {
18
+ this.lastIndex = 0;
19
+ }
20
+ }
@@ -0,0 +1,4 @@
1
+ declare const POLL_INTERVAL_MS = 5000;
2
+ export declare function isTerminalStatus(status: string): boolean;
3
+ export declare function sleep(ms: number): Promise<void>;
4
+ export { POLL_INTERVAL_MS };
@@ -0,0 +1,12 @@
1
+ const POLL_INTERVAL_MS = 5000;
2
+ // Mirrors the terminal members of the DB `project_deployment_status` enum.
3
+ // `not_available` is terminal: the hosting facade sets it (and promotes) when a
4
+ // provider lacks live status updates, so a poll loop must treat it as final.
5
+ const TERMINAL_STATUSES = new Set(['success', 'error', 'canceled', 'not_available']);
6
+ export function isTerminalStatus(status) {
7
+ return TERMINAL_STATUSES.has(status);
8
+ }
9
+ export function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+ export { POLL_INTERVAL_MS };
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod/v4';
2
+ export declare const deployTriggerResponseSchema: z.ZodObject<{
3
+ deploymentId: z.ZodString;
4
+ status: z.ZodString;
5
+ environment: z.ZodString;
6
+ previewName: z.ZodNullable<z.ZodString>;
7
+ }, z.core.$strip>;
8
+ export declare const attributionSchema: z.ZodObject<{
9
+ principalType: z.ZodEnum<{
10
+ user: "user";
11
+ "api-key": "api-key";
12
+ system: "system";
13
+ }>;
14
+ displayName: z.ZodString;
15
+ email: z.ZodNullable<z.ZodString>;
16
+ agentName: z.ZodNullable<z.ZodString>;
17
+ redacted: z.ZodBoolean;
18
+ }, z.core.$strip>;
19
+ export declare const deploymentStatusResponseSchema: z.ZodObject<{
20
+ deploymentId: z.ZodString;
21
+ status: z.ZodString;
22
+ environment: z.ZodString;
23
+ previewName: z.ZodNullable<z.ZodString>;
24
+ url: z.ZodNullable<z.ZodString>;
25
+ lastError: z.ZodNullable<z.ZodString>;
26
+ createdAt: z.ZodString;
27
+ stoppedAt: z.ZodNullable<z.ZodString>;
28
+ attribution: z.ZodNullable<z.ZodObject<{
29
+ principalType: z.ZodEnum<{
30
+ user: "user";
31
+ "api-key": "api-key";
32
+ system: "system";
33
+ }>;
34
+ displayName: z.ZodString;
35
+ email: z.ZodNullable<z.ZodString>;
36
+ agentName: z.ZodNullable<z.ZodString>;
37
+ redacted: z.ZodBoolean;
38
+ }, z.core.$strip>>;
39
+ }, z.core.$strip>;
40
+ export declare const deploymentsListResponseSchema: z.ZodObject<{
41
+ deployments: z.ZodArray<z.ZodObject<{
42
+ deploymentId: z.ZodString;
43
+ status: z.ZodString;
44
+ environment: z.ZodString;
45
+ previewName: z.ZodNullable<z.ZodString>;
46
+ url: z.ZodNullable<z.ZodString>;
47
+ createdAt: z.ZodString;
48
+ stoppedAt: z.ZodNullable<z.ZodString>;
49
+ attribution: z.ZodNullable<z.ZodObject<{
50
+ principalType: z.ZodEnum<{
51
+ user: "user";
52
+ "api-key": "api-key";
53
+ system: "system";
54
+ }>;
55
+ displayName: z.ZodString;
56
+ email: z.ZodNullable<z.ZodString>;
57
+ agentName: z.ZodNullable<z.ZodString>;
58
+ redacted: z.ZodBoolean;
59
+ }, z.core.$strip>>;
60
+ }, z.core.$strip>>;
61
+ }, z.core.$strip>;
62
+ export declare const buildLogLineSchema: z.ZodObject<{
63
+ text: z.ZodString;
64
+ level: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
65
+ error: "error";
66
+ warning: "warning";
67
+ }>>>;
68
+ timestamp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
69
+ }, z.core.$strip>;
70
+ export declare const deploymentLogsResponseSchema: z.ZodObject<{
71
+ lines: z.ZodNullable<z.ZodArray<z.ZodObject<{
72
+ text: z.ZodString;
73
+ level: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
74
+ error: "error";
75
+ warning: "warning";
76
+ }>>>;
77
+ timestamp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
78
+ }, z.core.$strip>>>;
79
+ supported: z.ZodBoolean;
80
+ deploymentStatus: z.ZodString;
81
+ }, z.core.$strip>;
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod/v4';
2
+ export const deployTriggerResponseSchema = z.object({
3
+ deploymentId: z.string(),
4
+ status: z.string(),
5
+ environment: z.string(),
6
+ previewName: z.string().nullable(),
7
+ });
8
+ export const attributionSchema = z.object({
9
+ principalType: z.enum(['user', 'api-key', 'system']),
10
+ displayName: z.string(),
11
+ email: z.string().nullable(),
12
+ agentName: z.string().nullable(),
13
+ redacted: z.boolean(),
14
+ });
15
+ export const deploymentStatusResponseSchema = z.object({
16
+ deploymentId: z.string(),
17
+ status: z.string(),
18
+ environment: z.string(),
19
+ previewName: z.string().nullable(),
20
+ url: z.string().nullable(),
21
+ lastError: z.string().nullable(),
22
+ createdAt: z.string(),
23
+ stoppedAt: z.string().nullable(),
24
+ attribution: attributionSchema.nullable(),
25
+ });
26
+ export const deploymentsListResponseSchema = z.object({
27
+ deployments: z.array(z.object({
28
+ deploymentId: z.string(),
29
+ status: z.string(),
30
+ environment: z.string(),
31
+ previewName: z.string().nullable(),
32
+ url: z.string().nullable(),
33
+ createdAt: z.string(),
34
+ stoppedAt: z.string().nullable(),
35
+ attribution: attributionSchema.nullable(),
36
+ })),
37
+ });
38
+ export const buildLogLineSchema = z.object({
39
+ text: z.string(),
40
+ level: z.enum(['error', 'warning']).nullable().optional(),
41
+ timestamp: z.number().nullable().optional(),
42
+ });
43
+ export const deploymentLogsResponseSchema = z.object({
44
+ lines: z.array(buildLogLineSchema).nullable(),
45
+ supported: z.boolean(),
46
+ deploymentStatus: z.string(),
47
+ });
@@ -0,0 +1,11 @@
1
+ import LaioutrBaseCommand from './laioutr-base-command.js';
2
+ import type { ProjectSlugs } from './project.js';
3
+ export default abstract class DeployBaseCommand extends LaioutrBaseCommand {
4
+ static flags: {
5
+ project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
+ key: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ cockpitApiHost: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ cwd: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ protected parseProject(project: string): ProjectSlugs;
11
+ }
@@ -0,0 +1,27 @@
1
+ import { Flags } from '@oclif/core';
2
+ import LaioutrBaseCommand from './laioutr-base-command.js';
3
+ import { parseProjectSlug } from './project.js';
4
+ export default class DeployBaseCommand extends LaioutrBaseCommand {
5
+ static flags = {
6
+ ...LaioutrBaseCommand.flags,
7
+ project: Flags.string({
8
+ char: 'p',
9
+ description: 'Project identifier in org/project format',
10
+ env: 'PROJECT',
11
+ required: true,
12
+ }),
13
+ key: Flags.string({
14
+ char: 'k',
15
+ description: 'Organization API key with project:deploy scope',
16
+ env: 'LAIOUTR_API_KEY',
17
+ required: true,
18
+ }),
19
+ };
20
+ parseProject(project) {
21
+ const parsed = parseProjectSlug(project);
22
+ if (!parsed) {
23
+ this.error('--project must be in org/project format');
24
+ }
25
+ return parsed;
26
+ }
27
+ }
@@ -0,0 +1,19 @@
1
+ export type DetailField = [label: string, value: string | null | undefined];
2
+ export interface FormatDetailOptions {
3
+ /** Optional heading printed above the aligned fields. */
4
+ title?: string;
5
+ /** Leading indent for each field line. Defaults to two spaces. */
6
+ indent?: string;
7
+ }
8
+ /**
9
+ * Formats an object's fields as an aligned `label: value` detail block:
10
+ *
11
+ * Deployment dep_abc123
12
+ * Status: success
13
+ * Environment: production
14
+ *
15
+ * Labels are padded so every value starts in the same column. Fields whose
16
+ * value is `null`, `undefined`, or empty are omitted, so a caller can list
17
+ * every possible field inline without pre-filtering the absent ones.
18
+ */
19
+ export declare function formatDetail(fields: DetailField[], options?: FormatDetailOptions): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Formats an object's fields as an aligned `label: value` detail block:
3
+ *
4
+ * Deployment dep_abc123
5
+ * Status: success
6
+ * Environment: production
7
+ *
8
+ * Labels are padded so every value starts in the same column. Fields whose
9
+ * value is `null`, `undefined`, or empty are omitted, so a caller can list
10
+ * every possible field inline without pre-filtering the absent ones.
11
+ */
12
+ export function formatDetail(fields, options = {}) {
13
+ const indent = options.indent ?? ' ';
14
+ const present = fields.filter((field) => Boolean(field[1]));
15
+ const labelWidth = Math.max(0, ...present.map(([label]) => label.length));
16
+ const lines = present.map(([label, value]) => `${indent}${`${label}:`.padEnd(labelWidth + 2)}${value}`);
17
+ return options.title ? [options.title, ...lines].join('\n') : lines.join('\n');
18
+ }
@@ -0,0 +1,14 @@
1
+ export interface ProjectSlugs {
2
+ organizationSlug: string;
3
+ projectSlug: string;
4
+ }
5
+ /**
6
+ * Parses an `org/project` project identifier into its organization and project
7
+ * slugs.
8
+ *
9
+ * Tolerates a `/p/` path segment (e.g. a pasted `org/p/project` URL) by
10
+ * normalising it to a plain separator. Returns `null` when the input is not
11
+ * exactly two non-empty slash-separated parts, so callers decide how to report
12
+ * the error.
13
+ */
14
+ export declare function parseProjectSlug(project: string): ProjectSlugs | null;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Parses an `org/project` project identifier into its organization and project
3
+ * slugs.
4
+ *
5
+ * Tolerates a `/p/` path segment (e.g. a pasted `org/p/project` URL) by
6
+ * normalising it to a plain separator. Returns `null` when the input is not
7
+ * exactly two non-empty slash-separated parts, so callers decide how to report
8
+ * the error.
9
+ */
10
+ export function parseProjectSlug(project) {
11
+ const parts = project.replace('/p/', '/').split('/');
12
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
13
+ return null;
14
+ }
15
+ return { organizationSlug: parts[0], projectSlug: parts[1] };
16
+ }