@shipdit/cli 0.4.1 → 0.5.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.
- package/README.md +21 -0
- package/dist/index.mjs +337 -41
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,3 +12,24 @@ shipd types gen
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
`shipd login` stores a scoped management API key. Use `SHIPD_API_KEY` for CI — keys carry org/project/environment metadata.
|
|
15
|
+
|
|
16
|
+
## Go projects
|
|
17
|
+
|
|
18
|
+
The CLI reads `shipd.config.go` directly. Declare an exported
|
|
19
|
+
`var Config = config.ShipdConfig{...}` using
|
|
20
|
+
`github.com/alasti-company/useshipd.com/sdks/go/config`. Go 1.23+ and Node.js 22.12+
|
|
21
|
+
are needed for CLI commands; the application runtime only needs Go.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
shipd diff
|
|
25
|
+
shipd push
|
|
26
|
+
shipd types gen
|
|
27
|
+
shipd pull
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Go input automatically selects Go output in `internal/flags/flags_gen.go`.
|
|
31
|
+
Generation emits per-flag accessors, key and variant constants, and `Defaults()`.
|
|
32
|
+
Pull writes Go definitions back to `shipd.config.go`. No JSON flag file is needed.
|
|
33
|
+
The definition file is evaluated on its own inside the Go module: imports and
|
|
34
|
+
helpers declared in that file work; sibling files are not loaded. Do not define
|
|
35
|
+
`main` in it. See `examples/go-examples` for a complete example.
|
package/dist/index.mjs
CHANGED
|
@@ -5,7 +5,27 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { createJiti } from "jiti";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
8
9
|
|
|
10
|
+
//#region src/lib/errors.ts
|
|
11
|
+
var CliError = class extends Error {
|
|
12
|
+
code;
|
|
13
|
+
exitCode;
|
|
14
|
+
details;
|
|
15
|
+
constructor(code, message, options) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "CliError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.exitCode = options?.exitCode ?? 1;
|
|
20
|
+
this.details = options?.details;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function exitCodeForError(error) {
|
|
24
|
+
if (error instanceof CliError) return error.exitCode;
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
9
29
|
//#region src/lib/config.ts
|
|
10
30
|
/** Hosted control plane. Used when no flag, env, project, global, or credential URL is set. */
|
|
11
31
|
const DEFAULT_API_URL = "https://api.useshipd.com";
|
|
@@ -44,7 +64,7 @@ function clearCredentials() {
|
|
|
44
64
|
function findProjectRoot(start = process.cwd()) {
|
|
45
65
|
let current = path.resolve(start);
|
|
46
66
|
while (true) {
|
|
47
|
-
if (fs.existsSync(path.join(current, "shipd.config.ts")) || fs.existsSync(path.join(current, ".shipd", "config.json"))) return current;
|
|
67
|
+
if (fs.existsSync(path.join(current, "shipd.config.ts")) || fs.existsSync(path.join(current, "shipd.config.go")) || fs.existsSync(path.join(current, "shipd.config.json")) || fs.existsSync(path.join(current, ".shipd", "config.json"))) return current;
|
|
48
68
|
const parent = path.dirname(current);
|
|
49
69
|
if (parent === current) return process.cwd();
|
|
50
70
|
current = parent;
|
|
@@ -60,7 +80,14 @@ function resolveRuntimeConfig(flags, options) {
|
|
|
60
80
|
const globalConfig = loadGlobalConfig();
|
|
61
81
|
const credentials = loadCredentials();
|
|
62
82
|
const ci = flags.ci === true || flags.yes === true || process.env.CI === "1" || process.env.CI === "true" || process.env.SHIPD_CI === "1";
|
|
63
|
-
const defaultConfigFile =
|
|
83
|
+
const defaultConfigFile = path.join(projectRoot, [
|
|
84
|
+
"shipd.config.ts",
|
|
85
|
+
"shipd.config.go",
|
|
86
|
+
"shipd.config.json"
|
|
87
|
+
].find((file) => fs.existsSync(path.join(projectRoot, file))) ?? "shipd.config.ts");
|
|
88
|
+
const configFile = flags.flagsConfig ? path.resolve(flags.flagsConfig) : projectConfig.configFile ?? defaultConfigFile;
|
|
89
|
+
const typesLanguage = projectConfig.typesLanguage ?? (path.extname(configFile) === ".go" ? "go" : "typescript");
|
|
90
|
+
if (typesLanguage !== "typescript" && typesLanguage !== "go") throw new CliError("validation_error", "typesLanguage must be typescript or go");
|
|
64
91
|
return {
|
|
65
92
|
apiUrl: flags.apiUrl ?? process.env.SHIPD_API_URL ?? projectConfig.apiUrl ?? globalConfig.apiUrl ?? credentials.apiUrl ?? options?.defaultApiUrl ?? "https://api.useshipd.com",
|
|
66
93
|
appUrl: process.env.SHIPD_APP_URL ?? projectConfig.appUrl ?? globalConfig.appUrl ?? "http://localhost:5173",
|
|
@@ -68,8 +95,10 @@ function resolveRuntimeConfig(flags, options) {
|
|
|
68
95
|
org: flags.org ?? process.env.SHIPD_ORG ?? projectConfig.org ?? globalConfig.org,
|
|
69
96
|
project: flags.project ?? process.env.SHIPD_PROJECT ?? projectConfig.project ?? globalConfig.project,
|
|
70
97
|
env: flags.env ?? process.env.SHIPD_ENV ?? projectConfig.env ?? globalConfig.env,
|
|
71
|
-
configFile
|
|
72
|
-
typesOut: projectConfig.typesOut ?? "src/generated/flags.ts",
|
|
98
|
+
configFile,
|
|
99
|
+
typesOut: projectConfig.typesOut ?? (typesLanguage === "go" ? "internal/flags/flags_gen.go" : "src/generated/flags.ts"),
|
|
100
|
+
typesLanguage,
|
|
101
|
+
typesPackage: projectConfig.typesPackage ?? "flags",
|
|
73
102
|
yes: ci,
|
|
74
103
|
json: flags.json === true,
|
|
75
104
|
cwd,
|
|
@@ -4200,25 +4229,6 @@ const f$1 = (e) => createAuthClient({
|
|
|
4200
4229
|
plugins: d$1(e)
|
|
4201
4230
|
});
|
|
4202
4231
|
|
|
4203
|
-
//#endregion
|
|
4204
|
-
//#region src/lib/errors.ts
|
|
4205
|
-
var CliError = class extends Error {
|
|
4206
|
-
code;
|
|
4207
|
-
exitCode;
|
|
4208
|
-
details;
|
|
4209
|
-
constructor(code, message, options) {
|
|
4210
|
-
super(message);
|
|
4211
|
-
this.name = "CliError";
|
|
4212
|
-
this.code = code;
|
|
4213
|
-
this.exitCode = options?.exitCode ?? 1;
|
|
4214
|
-
this.details = options?.details;
|
|
4215
|
-
}
|
|
4216
|
-
};
|
|
4217
|
-
function exitCodeForError(error) {
|
|
4218
|
-
if (error instanceof CliError) return error.exitCode;
|
|
4219
|
-
return 1;
|
|
4220
|
-
}
|
|
4221
|
-
|
|
4222
4232
|
//#endregion
|
|
4223
4233
|
//#region src/lib/client.ts
|
|
4224
4234
|
const AUTH_BASE_PATH = "/api/v1/auth";
|
|
@@ -4621,6 +4631,7 @@ const w = z.object({
|
|
|
4621
4631
|
});
|
|
4622
4632
|
});
|
|
4623
4633
|
const T = z.object({
|
|
4634
|
+
expectedVersion: z.number().int().positive().optional(),
|
|
4624
4635
|
flagId: z.string().min(1),
|
|
4625
4636
|
key: o.optional(),
|
|
4626
4637
|
name: z.string().min(1).optional(),
|
|
@@ -4633,6 +4644,7 @@ const E = z.object({ flagId: z.string().min(1) });
|
|
|
4633
4644
|
const D = z.object({ projectId: z.string().min(1) });
|
|
4634
4645
|
const O = z.object({ flagId: z.string().min(1) });
|
|
4635
4646
|
const k = z.object({
|
|
4647
|
+
expectedVersion: z.number().int().positive().optional(),
|
|
4636
4648
|
flagId: z.string().min(1),
|
|
4637
4649
|
environmentId: z.string().min(1),
|
|
4638
4650
|
enabled: z.boolean().optional(),
|
|
@@ -4640,6 +4652,7 @@ const k = z.object({
|
|
|
4640
4652
|
fallthrough: u.optional()
|
|
4641
4653
|
});
|
|
4642
4654
|
const A = z.object({
|
|
4655
|
+
expectedVersion: z.number().int().positive().optional(),
|
|
4643
4656
|
flagId: z.string().min(1),
|
|
4644
4657
|
environmentId: z.string().min(1),
|
|
4645
4658
|
enabled: z.boolean()
|
|
@@ -4785,6 +4798,188 @@ const J = z.object({
|
|
|
4785
4798
|
approvalRequests: z.array(z.string()).default([])
|
|
4786
4799
|
});
|
|
4787
4800
|
|
|
4801
|
+
//#endregion
|
|
4802
|
+
//#region src/lib/go-string.ts
|
|
4803
|
+
/** Go strings cannot represent JavaScript's unpaired UTF-16 surrogates. */
|
|
4804
|
+
function goString(value) {
|
|
4805
|
+
for (const character of value) {
|
|
4806
|
+
const code = character.charCodeAt(0);
|
|
4807
|
+
if (character.length === 1 && code >= 55296 && code <= 57343) throw new CliError("validation_error", `Cannot generate Go for a string containing an unpaired Unicode surrogate: ${JSON.stringify(value)}`);
|
|
4808
|
+
}
|
|
4809
|
+
return JSON.stringify(value);
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4812
|
+
//#endregion
|
|
4813
|
+
//#region src/lib/go-config.ts
|
|
4814
|
+
/** Read the package clause after leading whitespace and comments, not comment text. */
|
|
4815
|
+
function goPackageClause(source) {
|
|
4816
|
+
let offset = 0;
|
|
4817
|
+
const skipTrivia = () => {
|
|
4818
|
+
while (offset < source.length) {
|
|
4819
|
+
const trivia = /^(?:\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)/u.exec(source.slice(offset));
|
|
4820
|
+
if (!trivia) break;
|
|
4821
|
+
offset += trivia[0].length;
|
|
4822
|
+
}
|
|
4823
|
+
};
|
|
4824
|
+
skipTrivia();
|
|
4825
|
+
if (!/^package\b/u.test(source.slice(offset))) throw new Error("Go config must have a package declaration");
|
|
4826
|
+
offset += 7;
|
|
4827
|
+
skipTrivia();
|
|
4828
|
+
const name = /^[_\p{L}][_\p{L}\p{Nd}]*/u.exec(source.slice(offset))?.[0];
|
|
4829
|
+
if (!name) throw new Error("Go config must have a package name");
|
|
4830
|
+
return {
|
|
4831
|
+
name,
|
|
4832
|
+
start: offset,
|
|
4833
|
+
end: offset + name.length
|
|
4834
|
+
};
|
|
4835
|
+
}
|
|
4836
|
+
/** Evaluate a self-contained Go definition file within its own module. */
|
|
4837
|
+
async function loadGoConfig(file) {
|
|
4838
|
+
const source = fs.readFileSync(file, "utf8");
|
|
4839
|
+
const clause = goPackageClause(source);
|
|
4840
|
+
const directory = fs.mkdtempSync(path.join(path.dirname(file), ".shipd-go-"));
|
|
4841
|
+
const cleanup = () => fs.rmSync(directory, {
|
|
4842
|
+
recursive: true,
|
|
4843
|
+
force: true
|
|
4844
|
+
});
|
|
4845
|
+
process.once("exit", cleanup);
|
|
4846
|
+
let interrupted;
|
|
4847
|
+
let terminate;
|
|
4848
|
+
const interrupt = () => {
|
|
4849
|
+
interrupted = 130;
|
|
4850
|
+
terminate?.();
|
|
4851
|
+
};
|
|
4852
|
+
const shutdown = () => {
|
|
4853
|
+
interrupted = 143;
|
|
4854
|
+
terminate?.();
|
|
4855
|
+
};
|
|
4856
|
+
process.once("SIGINT", interrupt);
|
|
4857
|
+
process.once("SIGTERM", shutdown);
|
|
4858
|
+
try {
|
|
4859
|
+
fs.writeFileSync(path.join(directory, ".gitignore"), "*\n");
|
|
4860
|
+
fs.writeFileSync(path.join(directory, "definitions.go"), source.slice(0, clause.start) + "main" + source.slice(clause.end));
|
|
4861
|
+
fs.writeFileSync(path.join(directory, "main.go"), `package main
|
|
4862
|
+
import (
|
|
4863
|
+
"encoding/json"
|
|
4864
|
+
"fmt"
|
|
4865
|
+
"os"
|
|
4866
|
+
)
|
|
4867
|
+
func main() {
|
|
4868
|
+
file, err := os.Create(os.Args[1])
|
|
4869
|
+
if err == nil {
|
|
4870
|
+
err = json.NewEncoder(file).Encode(Config)
|
|
4871
|
+
if closeErr := file.Close(); err == nil { err = closeErr }
|
|
4872
|
+
}
|
|
4873
|
+
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
|
4874
|
+
}
|
|
4875
|
+
`);
|
|
4876
|
+
const output = path.join(directory, "config.json");
|
|
4877
|
+
try {
|
|
4878
|
+
await new Promise((resolve, reject) => {
|
|
4879
|
+
const grouped = process.platform !== "win32";
|
|
4880
|
+
const child = spawn("go", [
|
|
4881
|
+
"run",
|
|
4882
|
+
".",
|
|
4883
|
+
output
|
|
4884
|
+
], {
|
|
4885
|
+
cwd: directory,
|
|
4886
|
+
detached: grouped,
|
|
4887
|
+
stdio: [
|
|
4888
|
+
"ignore",
|
|
4889
|
+
"ignore",
|
|
4890
|
+
"pipe"
|
|
4891
|
+
]
|
|
4892
|
+
});
|
|
4893
|
+
let stderr = "";
|
|
4894
|
+
let failure;
|
|
4895
|
+
const kill = (signal) => {
|
|
4896
|
+
if (!child.pid) return;
|
|
4897
|
+
try {
|
|
4898
|
+
if (grouped) process.kill(-child.pid, signal);
|
|
4899
|
+
else child.kill(signal);
|
|
4900
|
+
} catch (error) {
|
|
4901
|
+
if (error.code !== "ESRCH") throw error;
|
|
4902
|
+
}
|
|
4903
|
+
};
|
|
4904
|
+
let killTimer;
|
|
4905
|
+
terminate = () => {
|
|
4906
|
+
kill("SIGTERM");
|
|
4907
|
+
killTimer = setTimeout(() => kill("SIGKILL"), 1e3);
|
|
4908
|
+
};
|
|
4909
|
+
const timer = setTimeout(() => {
|
|
4910
|
+
failure = /* @__PURE__ */ new Error("Go config evaluation timed out");
|
|
4911
|
+
kill("SIGKILL");
|
|
4912
|
+
}, 12e4);
|
|
4913
|
+
child.stderr.on("data", (chunk) => {
|
|
4914
|
+
stderr += chunk.toString();
|
|
4915
|
+
if (stderr.length > 8388608) {
|
|
4916
|
+
failure = /* @__PURE__ */ new Error("Go compiler output exceeds 8 MiB");
|
|
4917
|
+
kill("SIGKILL");
|
|
4918
|
+
}
|
|
4919
|
+
});
|
|
4920
|
+
child.once("error", (error) => {
|
|
4921
|
+
failure = error;
|
|
4922
|
+
});
|
|
4923
|
+
child.once("close", (code) => {
|
|
4924
|
+
clearTimeout(timer);
|
|
4925
|
+
clearTimeout(killTimer);
|
|
4926
|
+
terminate = void 0;
|
|
4927
|
+
if (failure || code !== 0) reject(Object.assign(failure ?? /* @__PURE__ */ new Error(`go run exited with code ${code}`), { stderr }));
|
|
4928
|
+
else resolve();
|
|
4929
|
+
});
|
|
4930
|
+
});
|
|
4931
|
+
} catch (error) {
|
|
4932
|
+
throw goRunError(error, path.basename(file));
|
|
4933
|
+
}
|
|
4934
|
+
return JSON.parse(fs.readFileSync(output, "utf8"));
|
|
4935
|
+
} finally {
|
|
4936
|
+
process.removeListener("SIGINT", interrupt);
|
|
4937
|
+
process.removeListener("SIGTERM", shutdown);
|
|
4938
|
+
process.removeListener("exit", cleanup);
|
|
4939
|
+
cleanup();
|
|
4940
|
+
if (interrupted !== void 0) process.exit(interrupted);
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
function goRunError(error, configName) {
|
|
4944
|
+
if (error.code === "ENOENT") return /* @__PURE__ */ new Error("Go 1.23+ is required to load Go flag definitions, but `go` was not found on PATH. Install Go from https://go.dev/dl and try again.");
|
|
4945
|
+
const stderr = String(error.stderr ?? "").replace(/(?:\.\/)?definitions\.go/g, configName).trim();
|
|
4946
|
+
const hint = /\bundefined:/.test(stderr) ? `\n${configName} must be self-contained: the CLI compiles only this file, not sibling Go files. Move helper declarations into it or import them from another package.` : "";
|
|
4947
|
+
return new Error(stderr ? `go run failed:\n${stderr}${hint}` : String(error));
|
|
4948
|
+
}
|
|
4949
|
+
function goNumber(value) {
|
|
4950
|
+
const literal = JSON.stringify(value);
|
|
4951
|
+
return Number.isSafeInteger(value) || /[.eE]/.test(literal) ? literal : `${literal}.0`;
|
|
4952
|
+
}
|
|
4953
|
+
function goValue(value) {
|
|
4954
|
+
if (value === null) return "nil";
|
|
4955
|
+
if (typeof value === "number") return goNumber(value);
|
|
4956
|
+
if (typeof value === "string") return goString(value);
|
|
4957
|
+
if (Array.isArray(value)) return `[]any{${value.map(goValue).join(", ")}}`;
|
|
4958
|
+
if (typeof value === "object") return `map[string]any{${Object.entries(value).map(([key, entry]) => `${goString(key)}: ${goValue(entry)}`).join(", ")}}`;
|
|
4959
|
+
return JSON.stringify(value);
|
|
4960
|
+
}
|
|
4961
|
+
/** Emits gofmt-clean Go: one block per flag, field values aligned as gofmt does. */
|
|
4962
|
+
function generateGoConfigSource(flags, packageName = "main") {
|
|
4963
|
+
const entries = flags.map((flag) => {
|
|
4964
|
+
const fields = [["Type", goString(flag.type)], ["Default", goValue(flag.default)]];
|
|
4965
|
+
if (flag.variants) fields.push(["Variants", `[]string{${flag.variants.map((v) => goString(v)).join(", ")}}`]);
|
|
4966
|
+
if (flag.description !== void 0) fields.push(["Description", goString(flag.description)]);
|
|
4967
|
+
if (flag.tags.length) fields.push(["Tags", `[]string{${flag.tags.map((v) => goString(v)).join(", ")}}`]);
|
|
4968
|
+
if (flag.lifecycle === "deprecated") fields.push(["Deprecated", "true"]);
|
|
4969
|
+
if (flag.disabled) fields.push(["Disabled", "true"]);
|
|
4970
|
+
const width = Math.max(...fields.map(([name]) => name.length)) + 1;
|
|
4971
|
+
const lines = fields.map(([name, value]) => `\t\t${`${name}:`.padEnd(width)} ${value},`);
|
|
4972
|
+
return `\t${goString(flag.key)}: {\n${lines.join("\n")}\n\t},`;
|
|
4973
|
+
});
|
|
4974
|
+
return `// Generated by shipd pull. Pull replaces this file; review changes before committing.
|
|
4975
|
+
package ${packageName}
|
|
4976
|
+
|
|
4977
|
+
import "github.com/alasti-company/useshipd.com/sdks/go/config"
|
|
4978
|
+
|
|
4979
|
+
var Config = config.ShipdConfig${entries.length ? `{\n${entries.join("\n")}\n}` : "{}"}
|
|
4980
|
+
`;
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4788
4983
|
//#endregion
|
|
4789
4984
|
//#region src/lib/local-config.ts
|
|
4790
4985
|
const localFlagsArraySchema = z.array(R).superRefine((flags, ctx) => {
|
|
@@ -4801,23 +4996,32 @@ const localFlagsArraySchema = z.array(R).superRefine((flags, ctx) => {
|
|
|
4801
4996
|
async function loadLocalFlags(configFile) {
|
|
4802
4997
|
const absolute = path.resolve(configFile);
|
|
4803
4998
|
if (!fs.existsSync(absolute)) throw new CliError("not_found", `Config file not found: ${absolute}`);
|
|
4804
|
-
|
|
4805
|
-
let loaded;
|
|
4999
|
+
let config;
|
|
4806
5000
|
try {
|
|
4807
|
-
|
|
5001
|
+
if (path.extname(absolute) === ".json") {
|
|
5002
|
+
config = JSON.parse(fs.readFileSync(absolute, "utf8"));
|
|
5003
|
+
if (config?.version !== 1) throw new Error("JSON flag config must have version: 1");
|
|
5004
|
+
} else if (path.extname(absolute) === ".go") config = await loadGoConfig(absolute);
|
|
5005
|
+
else {
|
|
5006
|
+
const loaded = await createJiti(import.meta.url, { interopDefault: true }).import(absolute);
|
|
5007
|
+
config = loaded.default ?? loaded;
|
|
5008
|
+
}
|
|
4808
5009
|
} catch (error) {
|
|
4809
5010
|
const message = error instanceof Error ? error.message : String(error);
|
|
4810
5011
|
throw new CliError("validation_error", `Failed to load config ${absolute}: ${message}`);
|
|
4811
5012
|
}
|
|
4812
|
-
|
|
4813
|
-
|
|
5013
|
+
if (!config?.flags || typeof config.flags !== "object" || Array.isArray(config.flags)) throw new CliError("validation_error", "Flag config must contain a flags object (defineFlags result or version-1 JSON)");
|
|
5014
|
+
for (const [key, flag] of Object.entries(config.flags)) {
|
|
5015
|
+
if (!flag || typeof flag !== "object" || Array.isArray(flag)) throw new CliError("validation_error", `Invalid definition for flag ${JSON.stringify(key)}`);
|
|
5016
|
+
if ("deprecated" in flag && typeof flag.deprecated !== "boolean") throw new CliError("validation_error", `Flag ${JSON.stringify(key)} deprecated must be a boolean`);
|
|
5017
|
+
}
|
|
4814
5018
|
const candidates = Object.entries(config.flags).map(([key, flag]) => ({
|
|
4815
5019
|
key,
|
|
4816
5020
|
type: flag.type,
|
|
4817
5021
|
default: flag.default,
|
|
4818
5022
|
description: flag.description,
|
|
4819
5023
|
tags: flag.tags ?? [],
|
|
4820
|
-
lifecycle: flag.lifecycle ?? "active",
|
|
5024
|
+
lifecycle: flag.lifecycle ?? (flag.deprecated === true ? "deprecated" : "active"),
|
|
4821
5025
|
variants: flag.variants,
|
|
4822
5026
|
disabled: flag.disabled
|
|
4823
5027
|
}));
|
|
@@ -4904,6 +5108,14 @@ function serializeTypeLiteral(value) {
|
|
|
4904
5108
|
if (value === null) return "null";
|
|
4905
5109
|
return JSON.stringify(value);
|
|
4906
5110
|
}
|
|
5111
|
+
/** Pull preserves JSON config workflows rather than introducing a TS dependency. */
|
|
5112
|
+
function generateJSONConfigSource(flags) {
|
|
5113
|
+
const entries = flags.map(({ key, ...definition }) => [key, definition]);
|
|
5114
|
+
return `${JSON.stringify({
|
|
5115
|
+
version: 1,
|
|
5116
|
+
flags: Object.fromEntries(entries)
|
|
5117
|
+
}, null, 2)}\n`;
|
|
5118
|
+
}
|
|
4907
5119
|
|
|
4908
5120
|
//#endregion
|
|
4909
5121
|
//#region src/lib/plan.ts
|
|
@@ -5166,24 +5378,100 @@ function registerProjectsCommands(program) {
|
|
|
5166
5378
|
});
|
|
5167
5379
|
}
|
|
5168
5380
|
|
|
5381
|
+
//#endregion
|
|
5382
|
+
//#region src/lib/go-typegen.ts
|
|
5383
|
+
const GO_KEYWORDS = new Set("break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var".split(" "));
|
|
5384
|
+
function goName(value) {
|
|
5385
|
+
let name = (value.normalize("NFC").match(/[\p{L}\p{Nd}]+/gu) ?? []).map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
5386
|
+
if (!/^\p{Lu}/u.test(name)) name = `Flag${name}`;
|
|
5387
|
+
return name;
|
|
5388
|
+
}
|
|
5389
|
+
function generateGoTypesSource(flags, packageName = "flags") {
|
|
5390
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(packageName) || packageName === "_" || GO_KEYWORDS.has(packageName)) throw new CliError("validation_error", `Invalid Go package name ${JSON.stringify(packageName)}`);
|
|
5391
|
+
const used = /* @__PURE__ */ new Set(["Defaults"]);
|
|
5392
|
+
const claim = (name, key) => {
|
|
5393
|
+
if (used.has(name)) throw new CliError("validation_error", `Go identifier ${name} collides for flag ${JSON.stringify(key)}; rename the flag or variant`);
|
|
5394
|
+
used.add(name);
|
|
5395
|
+
return name;
|
|
5396
|
+
};
|
|
5397
|
+
const declarations = flags.map((flag) => {
|
|
5398
|
+
const name = claim(goName(flag.key), flag.key);
|
|
5399
|
+
const keyName = claim(`${name}Key`, flag.key);
|
|
5400
|
+
const lines = [`const ${keyName} = ${goString(flag.key)}`];
|
|
5401
|
+
let returnType;
|
|
5402
|
+
let expression;
|
|
5403
|
+
switch (flag.type) {
|
|
5404
|
+
case "boolean":
|
|
5405
|
+
returnType = "bool";
|
|
5406
|
+
expression = `client.IsEnabled(${keyName}, context)`;
|
|
5407
|
+
break;
|
|
5408
|
+
case "number":
|
|
5409
|
+
returnType = "float64";
|
|
5410
|
+
expression = `client.GetNumber(${keyName}, context)`;
|
|
5411
|
+
break;
|
|
5412
|
+
case "json":
|
|
5413
|
+
returnType = "any";
|
|
5414
|
+
expression = `client.GetJSON(${keyName}, context)`;
|
|
5415
|
+
break;
|
|
5416
|
+
case "variant":
|
|
5417
|
+
returnType = claim(`${name}Variant`, flag.key);
|
|
5418
|
+
lines.push(`type ${returnType} string`);
|
|
5419
|
+
for (const variant of flag.variants ?? []) {
|
|
5420
|
+
const constant = claim(`${name}${goName(variant)}`, flag.key);
|
|
5421
|
+
lines.push(`const ${constant} ${returnType} = ${goString(variant)}`);
|
|
5422
|
+
}
|
|
5423
|
+
expression = `${returnType}(client.GetVariant(${keyName}, context))`;
|
|
5424
|
+
}
|
|
5425
|
+
lines.push(`func ${name}(client *shipd.Client, context shipd.EvaluationContext) ${returnType} {\n\treturn ${expression}\n}`);
|
|
5426
|
+
return lines.join("\n\n");
|
|
5427
|
+
}).join("\n\n");
|
|
5428
|
+
const defaults = Object.fromEntries(flags.map((flag) => [flag.key, flag.default]));
|
|
5429
|
+
return `// Code generated by shipd types gen --language go; DO NOT EDIT.
|
|
5430
|
+
package ${packageName}
|
|
5431
|
+
|
|
5432
|
+
import (
|
|
5433
|
+
\t"encoding/json"
|
|
5434
|
+
${flags.length ? " shipd \"github.com/alasti-company/useshipd.com/sdks/go\"\n" : ""})
|
|
5435
|
+
|
|
5436
|
+
// Defaults returns a fresh map of the config's defaults for shipd.Options.
|
|
5437
|
+
func Defaults() map[string]any {
|
|
5438
|
+
\tvar defaults map[string]any
|
|
5439
|
+
\t_ = json.Unmarshal([]byte(${JSON.stringify(JSON.stringify(defaults))}), &defaults)
|
|
5440
|
+
\treturn defaults
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
${declarations}
|
|
5444
|
+
`.trimEnd() + "\n";
|
|
5445
|
+
}
|
|
5446
|
+
|
|
5169
5447
|
//#endregion
|
|
5170
5448
|
//#region src/lib/typegen.ts
|
|
5171
|
-
async function runTypegen(runtime, outPath = runtime.typesOut) {
|
|
5172
|
-
|
|
5449
|
+
async function runTypegen(runtime, outPath = runtime.typesOut, options = {}) {
|
|
5450
|
+
return writeTypegen(prepareTypegen(runtime, await loadLocalFlags(runtime.configFile), outPath, options));
|
|
5451
|
+
}
|
|
5452
|
+
function prepareTypegen(runtime, flags, outPath = runtime.typesOut, options = {}) {
|
|
5173
5453
|
const absolute = path.resolve(outPath);
|
|
5174
|
-
const
|
|
5175
|
-
|
|
5176
|
-
fs.writeFileSync(absolute, source);
|
|
5454
|
+
const language = options.language ?? runtime.typesLanguage ?? "typescript";
|
|
5455
|
+
if (language !== "go" && language !== "typescript") throw new CliError("validation_error", "Type generation language must be typescript or go");
|
|
5177
5456
|
return {
|
|
5457
|
+
source: language === "go" ? generateGoTypesSource(flags, options.packageName ?? runtime.typesPackage ?? "flags") : generateTypesSource(flags),
|
|
5178
5458
|
written: absolute,
|
|
5179
5459
|
flagCount: flags.length
|
|
5180
5460
|
};
|
|
5181
5461
|
}
|
|
5462
|
+
function writeTypegen(prepared) {
|
|
5463
|
+
fs.mkdirSync(path.dirname(prepared.written), { recursive: true });
|
|
5464
|
+
fs.writeFileSync(prepared.written, prepared.source);
|
|
5465
|
+
return {
|
|
5466
|
+
written: prepared.written,
|
|
5467
|
+
flagCount: prepared.flagCount
|
|
5468
|
+
};
|
|
5469
|
+
}
|
|
5182
5470
|
|
|
5183
5471
|
//#endregion
|
|
5184
5472
|
//#region src/commands/pull.ts
|
|
5185
5473
|
function registerPullCommand(program) {
|
|
5186
|
-
program.command("pull").description("Regenerate
|
|
5474
|
+
program.command("pull").description("Regenerate the flag config from the server and run typegen").action(async (_opts, command) => {
|
|
5187
5475
|
const runtime = getRuntime(command);
|
|
5188
5476
|
const client = await createCliApiClient(runtime);
|
|
5189
5477
|
const { projectId, environmentId } = await resolveIds(client, runtime);
|
|
@@ -5200,11 +5488,13 @@ function registerPullCommand(program) {
|
|
|
5200
5488
|
variants: flag.variants,
|
|
5201
5489
|
disabled: flag.disabled
|
|
5202
5490
|
}));
|
|
5203
|
-
const
|
|
5491
|
+
const packageName = path.extname(runtime.configFile) === ".go" && fs.existsSync(runtime.configFile) ? goPackageClause(fs.readFileSync(runtime.configFile, "utf8")).name : "main";
|
|
5492
|
+
const source = path.extname(runtime.configFile) === ".json" ? generateJSONConfigSource(localFlags) : path.extname(runtime.configFile) === ".go" ? generateGoConfigSource(localFlags, packageName) : generateShipdConfigSource(localFlags);
|
|
5493
|
+
const preparedTypes = prepareTypegen(runtime, localFlags);
|
|
5204
5494
|
const outPath = path.resolve(runtime.configFile);
|
|
5205
5495
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
5206
5496
|
fs.writeFileSync(outPath, source);
|
|
5207
|
-
const types =
|
|
5497
|
+
const types = writeTypegen(preparedTypes);
|
|
5208
5498
|
printResult(runtime, {
|
|
5209
5499
|
written: outPath,
|
|
5210
5500
|
flagCount: localFlags.length,
|
|
@@ -5259,6 +5549,7 @@ function registerPushCommand(program) {
|
|
|
5259
5549
|
}
|
|
5260
5550
|
if (planHasDefinitionChanges(plan) && !runtime.json) console.error("Note: definition/ownership changes apply to all environments (requires production permission).");
|
|
5261
5551
|
if (!await confirm(runtime, "Apply this plan?")) throw new CliError("validation_error", "Push cancelled (pass --yes or --ci to apply non-interactively)");
|
|
5552
|
+
const preparedTypes = prepareTypegen(runtime, localFlags);
|
|
5262
5553
|
const expectedVersions = expectedVersionsFromChanges(planned.changes);
|
|
5263
5554
|
const applied = await client.flag.push({
|
|
5264
5555
|
projectId,
|
|
@@ -5271,7 +5562,7 @@ function registerPushCommand(program) {
|
|
|
5271
5562
|
expectedVersions
|
|
5272
5563
|
}).catch(mapApiError);
|
|
5273
5564
|
const appliedPlan = buildSyncPlan(applied.changes);
|
|
5274
|
-
const types =
|
|
5565
|
+
const types = writeTypegen(preparedTypes);
|
|
5275
5566
|
const approvalRequests = applied.approvalRequests ?? [];
|
|
5276
5567
|
printResult(runtime, {
|
|
5277
5568
|
plan: appliedPlan,
|
|
@@ -5366,9 +5657,14 @@ function registerSnapshotsCommands(program) {
|
|
|
5366
5657
|
//#endregion
|
|
5367
5658
|
//#region src/commands/types.ts
|
|
5368
5659
|
function registerTypesCommands(program) {
|
|
5369
|
-
program.command("types").description("Type generation helpers").command("gen").description("Generate TypeScript types from
|
|
5660
|
+
program.command("types").description("Type generation helpers").command("gen").description("Generate TypeScript types or Go accessors from the local flag config").addOption(new Option("--language <language>", "Output language").choices(["typescript", "go"])).option("--package <name>", "Go package name (default: flags)").option("-o, --out <path>", "Output file path").action(async (opts, command) => {
|
|
5370
5661
|
const runtime = getRuntime(command);
|
|
5371
|
-
const
|
|
5662
|
+
const language = opts.language ?? runtime.typesLanguage ?? "typescript";
|
|
5663
|
+
const defaultOutput = language === (runtime.typesLanguage ?? "typescript") ? runtime.typesOut : language === "go" ? "internal/flags/flags_gen.go" : "src/generated/flags.ts";
|
|
5664
|
+
const result = await runTypegen(runtime, opts.out ?? defaultOutput, {
|
|
5665
|
+
language,
|
|
5666
|
+
packageName: opts.package
|
|
5667
|
+
});
|
|
5372
5668
|
printResult(runtime, result, `Wrote types for ${result.flagCount} flag(s) to ${result.written}`);
|
|
5373
5669
|
});
|
|
5374
5670
|
}
|
|
@@ -5377,7 +5673,7 @@ function registerTypesCommands(program) {
|
|
|
5377
5673
|
//#region src/index.ts
|
|
5378
5674
|
/** Detected before parse so usage errors can suppress Commander text under `--json`. */
|
|
5379
5675
|
const jsonRequested = process.argv.includes("--json");
|
|
5380
|
-
const program = new Command().name("shipd").description("shipd CLI — flags as code").option("--json", "Machine-readable JSON output", false).option("--yes", "Skip confirmation prompts", false).option("--ci", "Non-interactive mode (same as --yes / CI=1)", false).option("--api-url <url>", `API origin (default: ${DEFAULT_API_URL})`).option("--org <slug>", "Organization slug (session: select; API key: must match)").option("--project <slug>", "Project slug").option("--env <slug>", "Environment slug").option("--config <path>", "Project settings JSON (.shipd/config.json shape)").option("--flags-config <path>", "Path to shipd.config.ts").exitOverride().showHelpAfterError(!jsonRequested);
|
|
5676
|
+
const program = new Command().name("shipd").description("shipd CLI — flags as code").option("--json", "Machine-readable JSON output", false).option("--yes", "Skip confirmation prompts", false).option("--ci", "Non-interactive mode (same as --yes / CI=1)", false).option("--api-url <url>", `API origin (default: ${DEFAULT_API_URL})`).option("--org <slug>", "Organization slug (session: select; API key: must match)").option("--project <slug>", "Project slug").option("--env <slug>", "Environment slug").option("--config <path>", "Project settings JSON (.shipd/config.json shape)").option("--flags-config <path>", "Path to shipd.config.ts, shipd.config.go, or shipd.config.json").exitOverride().showHelpAfterError(!jsonRequested);
|
|
5381
5677
|
if (jsonRequested) program.configureOutput({
|
|
5382
5678
|
writeErr: () => {},
|
|
5383
5679
|
writeOut: () => {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipdit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "shipd's command-line interface",
|
|
5
5
|
"homepage": "https://docs.useshipd.com",
|
|
6
6
|
"bugs": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"commander": "^15.0.0",
|
|
20
20
|
"jiti": "^2.5.1",
|
|
21
|
-
"shipdit": "0.
|
|
21
|
+
"shipdit": "0.4.0",
|
|
22
22
|
"zod": "^4.6.5"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|